🪪 refactor: Require Remote OIDC Audience for Agents API OAuth (#13066)

This commit is contained in:
Danny Avila 2026-05-11 08:38:13 -04:00 committed by GitHub
parent 7129b1b1e4
commit 52ccb1379b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 61 additions and 6 deletions

View file

@ -135,6 +135,7 @@ function makeConfig(
oidc: {
enabled: true,
issuer: BASE_ISSUER,
audience: 'remote-agent-api',
jwksUri: BASE_JWKS_URI,
...oidcOverrides,
},
@ -384,6 +385,17 @@ describe('createRemoteAgentAuth', () => {
expect(json).toHaveBeenCalledWith({ error: 'Internal server error' });
expect(mockNext).not.toHaveBeenCalled();
});
it('returns 500 when OIDC is enabled without an audience', async () => {
const deps = makeDeps(makeConfig({ audience: undefined }, { enabled: false }));
const { res, status, json } = makeRes();
await createRemoteAgentAuth(deps)(makeReq() as Request, res, mockNext);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Internal server error' });
expect(mockNext).not.toHaveBeenCalled();
});
});
describe('when OIDC verification succeeds', () => {
@ -396,6 +408,9 @@ describe('createRemoteAgentAuth', () => {
await createRemoteAgentAuth(deps)(req as Request, res, mockNext);
expect(req.user).toMatchObject({ id: 'uid123', email: 'agent@test.com' });
expect((jwt.verify as jest.Mock).mock.calls[0][2]).toEqual(
expect.objectContaining({ audience: 'remote-agent-api' }),
);
expect(mockNext).toHaveBeenCalledWith();
expect(deps.apiKeyMiddleware).not.toHaveBeenCalled();
});
@ -407,7 +422,7 @@ describe('createRemoteAgentAuth', () => {
deps.getAppConfig.mockImplementation(async (options) =>
options?.tenantId === 'tenant-strict'
? makeConfig({ audience: 'tenant-audience', scope: 'remote_agent' }, { enabled: false })
: makeConfig({ audience: undefined, scope: undefined }, { enabled: true }),
: makeConfig({ scope: undefined }, { enabled: true }),
);
const req = makeReq({ authorization: `Bearer ${FAKE_TOKEN}` });

View file

@ -25,7 +25,7 @@ type OidcConfig = NonNullable<
>;
type AgentAuthConfig = NonNullable<NonNullable<TAgentsEndpoint['remoteApi']>['auth']>;
type EnabledOidcConfig = OidcConfig & { issuer: string };
type EnabledOidcConfig = OidcConfig & { audience: string; issuer: string };
type JwksCacheOptions = {
enabled: boolean;
maxAge: number;
@ -233,8 +233,8 @@ function getVerifyOptions(oidcConfig: EnabledOidcConfig): VerifyOptions {
return {
algorithms: JWT_ALGORITHMS,
audience: oidcConfig.audience,
issuer,
...(oidcConfig.audience ? { audience: oidcConfig.audience } : {}),
};
}
@ -268,7 +268,14 @@ function getEnabledOidcConfig(
): EnabledOidcConfig | undefined {
if (authConfig?.oidc?.enabled !== true) return undefined;
if (!authConfig.oidc.issuer) throw new Error('OIDC issuer is required when OIDC auth is enabled');
return { ...authConfig.oidc, issuer: authConfig.oidc.issuer };
if (!authConfig.oidc.audience) {
throw new Error('OIDC audience is required when OIDC auth is enabled');
}
return {
...authConfig.oidc,
audience: authConfig.oidc.audience,
issuer: authConfig.oidc.issuer,
};
}
function isApiKeyEnabled(config: AppConfig): boolean {
@ -491,9 +498,14 @@ export function createRemoteAgentAuth({
res.status(500).json({ error: 'Internal server error' });
return;
}
if (!authConfig.oidc.audience) {
logger.error('[remoteAgentAuth] OIDC audience is required when OIDC auth is enabled');
res.status(500).json({ error: 'Internal server error' });
return;
}
const oidcConfig = getEnabledOidcConfig(authConfig);
if (!oidcConfig) throw new Error('OIDC issuer is required when OIDC auth is enabled');
if (!oidcConfig) throw new Error('OIDC configuration is required when OIDC auth is enabled');
const token = extractBearer(req.headers.authorization);
if (token == null) {

View file

@ -374,6 +374,7 @@ describe('agentsEndpointSchema', () => {
auth: {
oidc: {
enabled: true,
audience: 'remote-agent-api',
},
},
},
@ -384,6 +385,7 @@ describe('agentsEndpointSchema', () => {
oidc: {
enabled: true,
issuer: 'my-realm',
audience: 'remote-agent-api',
},
},
},
@ -393,6 +395,21 @@ describe('agentsEndpointSchema', () => {
expect(invalidIssuer.success).toBe(false);
});
it('requires an audience when remote OIDC auth is enabled', () => {
const result = agentsEndpointSchema.safeParse({
remoteApi: {
auth: {
oidc: {
enabled: true,
issuer: 'https://auth.example.com',
},
},
},
});
expect(result.success).toBe(false);
});
it('requires HTTPS remote OIDC issuer and JWKS URLs outside localhost', () => {
const insecureIssuer = agentsEndpointSchema.safeParse({
remoteApi: {
@ -400,6 +417,7 @@ describe('agentsEndpointSchema', () => {
oidc: {
enabled: true,
issuer: 'http://auth.example.com',
audience: 'remote-agent-api',
},
},
},
@ -410,6 +428,7 @@ describe('agentsEndpointSchema', () => {
oidc: {
enabled: true,
issuer: 'https://auth.example.com',
audience: 'remote-agent-api',
jwksUri: 'http://auth.example.com/jwks',
},
},
@ -427,6 +446,7 @@ describe('agentsEndpointSchema', () => {
oidc: {
enabled: true,
issuer: 'http://localhost:8080/realms/test',
audience: 'remote-agent-api',
jwksUri: 'http://127.0.0.1:8080/realms/test/protocol/openid-connect/certs',
},
},
@ -443,6 +463,7 @@ describe('agentsEndpointSchema', () => {
oidc: {
enabled: true,
issuer: 'https://auth.example.com',
audience: 'remote-agent-api',
scope: 'remote_agent,admin',
},
},

View file

@ -493,7 +493,7 @@ const remoteApiOidcSchema = z
.object({
enabled: z.boolean().default(false),
issuer: remoteApiOidcUrlSchema.optional(),
audience: z.string().optional(),
audience: z.string().min(1).optional(),
jwksUri: remoteApiOidcUrlSchema.optional(),
scope: remoteApiOidcScopeSchema.optional(),
})
@ -505,6 +505,13 @@ const remoteApiOidcSchema = z
message: 'issuer is required when OIDC auth is enabled',
});
}
if (oidc.enabled === true && !oidc.audience) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['audience'],
message: 'audience is required when OIDC auth is enabled',
});
}
});
const remoteApiAuthSchema = z.object({