diff --git a/src/shared/modules/global/jwt.service.spec.ts b/src/shared/modules/global/jwt.service.spec.ts index 18a0787..7ae355b 100644 --- a/src/shared/modules/global/jwt.service.spec.ts +++ b/src/shared/modules/global/jwt.service.spec.ts @@ -1,4 +1,5 @@ import * as jwt from 'jsonwebtoken'; +import { Scope } from 'src/shared/enums/scopes.enum'; import { JwtService } from './jwt.service'; function signToken(payload: Record): string { @@ -44,6 +45,33 @@ describe('JwtService', () => { expect(user.userId).toBe('auth0|abcd'); }); + it('extracts Auth0 client-credentials scopes for machine subjects', async () => { + const token = signToken({ + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }); + + const user = await service.validateToken(token); + + expect(user).toEqual( + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + isMachine: true, + scopes: expect.arrayContaining([ + Scope.PROJECTS_READ, + Scope.PROJECT_MEMBERS_WRITE, + ]), + tokenPayload: expect.objectContaining({ + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + }), + }), + ); + }); + it('extracts lower-cased email from namespaced email claim', async () => { const token = signToken({ sub: 'auth0|abcd', diff --git a/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 568fecc..7550366 100644 --- a/test/project-member.e2e-spec.ts +++ b/test/project-member.e2e-spec.ts @@ -38,6 +38,20 @@ const tokenUsers: Record = { }, }; +function createAuth0MachineMemberWriteUser(): JwtUser { + return { + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: { + sub: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + azp: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8', + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }, + }; +} + @Controller('/projects/project-member-test') class ProjectMemberTestController { @Get('/health') @@ -305,6 +319,86 @@ describe('Project Member endpoints (e2e)', () => { ); }); + it('creates members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .post('/v6/projects/1001/members') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .send({ userId: '101125', role: 'observer' }) + .expect(201); + + expect(projectMemberServiceMock.addMember).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ userId: '101125', role: 'observer' }), + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + undefined, + ); + }); + + it('updates members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .patch('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .send({ role: 'observer' }) + .expect(200); + + expect(projectMemberServiceMock.updateMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ role: 'observer' }), + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + undefined, + ); + }); + + it('deletes members for Auth0-shaped m2m token when tokenPayload scope is broader than user.scopes', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce( + createAuth0MachineMemberWriteUser(), + ); + + await request(app.getHttpServer()) + .delete('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write-auth0-shape') + .expect(204); + + expect(projectMemberServiceMock.deleteMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ + userId: 'VYWpLOVcDTMvUUlZmNaqhwxjqXWn0qu8@clients', + scopes: [Scope.PROJECTS_READ], + isMachine: true, + tokenPayload: expect.objectContaining({ + gty: 'client-credentials', + scope: `${Scope.PROJECTS_READ} ${Scope.PROJECT_MEMBERS_WRITE}`, + }), + }), + ); + }); + it('lists members for m2m token with project-member read scope', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ scopes: [Scope.PROJECT_MEMBERS_READ],