From 7b36e240791bd1ece07eab4de8c1df769a6d7314 Mon Sep 17 00:00:00 2001 From: Artyom Bogachenko Date: Tue, 31 Mar 2026 03:49:03 +0300 Subject: [PATCH] add scope validation --- .../src/middleware/remoteAgentAuth.spec.ts | 58 +++++++++++++++++++ .../api/src/middleware/remoteAgentAuth.ts | 17 +++++- packages/data-provider/src/config.ts | 1 + 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index dc24584c7b..1a7a7d7fa1 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -499,4 +499,62 @@ describe('createRemoteAgentAuth', () => { expect(mockUpdateUser).not.toHaveBeenCalled(); }); }); + + describe('scope validation', () => { + it('returns 401 when required scope is missing from token', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com', scope: 'openid profile' }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' }, { enabled: false })); + const { res, status, json } = makeRes(); + + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + res, + mockNext, + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + }); + + it('passes when required scope is present in token', async () => { + setupOidcMocks({ + sub: 'sub123', + email: 'agent@test.com', + scope: 'openid profile remote_agent', + }); + (findOpenIDUser as jest.Mock).mockResolvedValue({ + user: { ...mockUser }, + error: null, + migration: false, + }); + + const deps = makeDeps(makeConfig({ scope: 'remote_agent' })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('passes when scope is not configured (backward compat)', async () => { + setupOidcMocks({ sub: 'sub123', email: 'agent@test.com' }); // no scope claim at all + (findOpenIDUser as jest.Mock).mockResolvedValue({ + user: { ...mockUser }, + error: null, + migration: false, + }); + + const deps = makeDeps(makeConfig({ scope: undefined })); + await createRemoteAgentAuth(deps)( + makeReq({ authorization: `Bearer ${FAKE_TOKEN}` }) as Request, + makeRes().res, + mockNext, + ); + + expect(mockNext).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index c63e614710..0735119d49 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -159,7 +159,7 @@ async function resolveUser( const updateData: Partial = {}; - if (migration) { + if (migration && payload.sub != null) { updateData.provider = 'openid'; updateData.openidId = payload.sub; } @@ -195,6 +195,7 @@ async function resolveUser( * issuer: * jwksUri: * audience: + * scope: * ``` */ export function createRemoteAgentAuth({ @@ -223,6 +224,20 @@ export function createRemoteAgentAuth({ try { const payload = await verifyOidcBearer(token, authConfig.oidc); + + if (authConfig.oidc.scope != null) { + const rawScope = payload['scp'] ?? payload['scope']; + const tokenScopes: string[] = Array.isArray(rawScope) + ? rawScope + : ((rawScope as string | undefined)?.split(' ') ?? []); + if (!tokenScopes.includes(authConfig.oidc.scope)) { + logger.warn(`[remoteAgentAuth] Token missing required scope: ${authConfig.oidc.scope}`); + if (apiKeyEnabled) return apiKeyMiddleware(req, res, next); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + } + const user = await resolveUser(token, payload, findUser, updateUser); if (user == null) { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index fe763fb18e..f13d589242 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -291,6 +291,7 @@ const remoteApiAuthSchema = z.object({ issuer: z.string(), audience: z.string().optional(), jwksUri: z.string().optional(), + scope: z.string().optional(), }) .optional(), });