add scope validation

This commit is contained in:
Artyom Bogachenko 2026-03-31 03:49:03 +03:00
parent 5232ee5822
commit 7b36e24079
3 changed files with 75 additions and 1 deletions

View file

@ -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();
});
});
});

View file

@ -159,7 +159,7 @@ async function resolveUser(
const updateData: Partial<IUser> = {};
if (migration) {
if (migration && payload.sub != null) {
updateData.provider = 'openid';
updateData.openidId = payload.sub;
}
@ -195,6 +195,7 @@ async function resolveUser(
* issuer: <issuer>
* jwksUri: <jwksUri>
* audience: <audience>
* scope: <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) {

View file

@ -291,6 +291,7 @@ const remoteApiAuthSchema = z.object({
issuer: z.string(),
audience: z.string().optional(),
jwksUri: z.string().optional(),
scope: z.string().optional(),
})
.optional(),
});