diff --git a/lib/constants.ts b/lib/constants.ts index ea7e93bbc..9e6f39cf2 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -8,3 +8,5 @@ export const authorizedTenantsClaimName = 'tenants'; export const permissionsClaimName = 'permissions'; /** The key of the roles claims in the claims map either under tenant or top level */ export const rolesClaimName = 'roles'; +/** The key of the scope claim in the claims map (OAuth 2.0 standard) */ +export const scopeClaimName = 'scope'; diff --git a/lib/errors.ts b/lib/errors.ts index 1b018540d..d80e11912 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -4,6 +4,7 @@ export default { missingArguments: 'E011002', invalidRequest: 'E011003', invalidArguments: 'E011004', + insufficientScopes: 'E011005', wrongOTPCode: 'E061102', tooManyOTPAttempts: 'E061103', enchantedLinkPending: 'E062503', diff --git a/lib/index.test.ts b/lib/index.test.ts index 17cf3d3c5..285341575 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -20,6 +20,11 @@ let publicKeys: JWK; let tokenAudA: string; let tokenAudB: string; let expiredTokenAudA: string; +// Scope-specific tokens +let tokenScopeArray: string; +let tokenScopeReadWrite: string; +let tokenScopeReadOnly: string; +let tokenNoScopes: string; let permAuthInfo: AuthenticationInfo; let permTenantAuthInfo: AuthenticationInfo; @@ -89,6 +94,31 @@ describe('sdk', () => { .setIssuer('project-id') .setExpirationTime(1181398111) .sign(privateKey); + // Scope-specific tokens + tokenScopeArray = await new SignJWT({ scopes: ['read', 'write'] }) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('project-id') + .setExpirationTime(1981398111) + .sign(privateKey); + tokenScopeReadWrite = await new SignJWT({ scope: 'read write' }) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('project-id') + .setExpirationTime(1981398111) + .sign(privateKey); + tokenScopeReadOnly = await new SignJWT({ scope: 'read' }) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('project-id') + .setExpirationTime(1981398111) + .sign(privateKey); + tokenNoScopes = await new SignJWT({}) + .setProtectedHeader({ alg: 'ES384', kid: '0ad99869f2d4e57f3f71c68300ba84fa' }) + .setIssuedAt() + .setIssuer('project-id') + .setExpirationTime(1981398111) + .sign(privateKey); permAuthInfo = { jwt: 'jwt', token: { [permissionsClaimName]: ['foo', 'bar'], [rolesClaimName]: ['abc', 'xyz'] }, @@ -203,6 +233,92 @@ describe('sdk', () => { }); }); + describe('scope validation', () => { + it('should reject when scopes are required but missing in token', async () => { + await expect((sdk as any).validateSession(tokenNoScopes, { scopes: 'read' })).rejects.toThrow( + 'session validation failed', + ); + }); + + it('should reject when scopes mismatch in token for validateSession', async () => { + await expect( + (sdk as any).validateSession(tokenScopeReadOnly, { scopes: 'write' }), + ).rejects.toThrow('session validation failed'); + }); + + it('should accept when all required scopes are present (single scope)', async () => { + await expect( + (sdk as any).validateSession(tokenScopeReadWrite, { scopes: 'read' }), + ).resolves.toHaveProperty('jwt', tokenScopeReadWrite); + }); + + it('should accept when all required scopes are present (multiple scopes)', async () => { + await expect( + (sdk as any).validateSession(tokenScopeReadWrite, { scopes: ['read', 'write'] }), + ).resolves.toHaveProperty('jwt', tokenScopeReadWrite); + }); + + it('should accept when token has scopes as array instead of space-separated string', async () => { + await expect( + (sdk as any).validateSession(tokenScopeArray, { scopes: 'read' }), + ).resolves.toHaveProperty('jwt', tokenScopeArray); + }); + + it('should reject when token is missing some required scopes', async () => { + await expect( + (sdk as any).validateSession(tokenScopeReadOnly, { scopes: ['read', 'write'] }), + ).rejects.toThrow('session validation failed'); + }); + + it('should reject when scopes mismatch in validateJwt', async () => { + await expect( + (sdk as any).validateJwt(tokenScopeReadOnly, { scopes: 'write' }), + ).rejects.toThrow('insufficient scopes'); + }); + + it('should accept when validateJwt has matching scopes', async () => { + await expect( + (sdk as any).validateJwt(tokenScopeReadWrite, { scopes: ['read', 'write'] }), + ).resolves.toHaveProperty('jwt', tokenScopeReadWrite); + }); + + it('should reject when refreshSession returns session with insufficient scopes', async () => { + const spyRefresh = jest.spyOn(sdk, 'refresh').mockResolvedValueOnce({ + ok: true, + data: { sessionJwt: tokenScopeReadOnly }, + } as SdkResponse); + + await expect((sdk as any).refreshSession(validToken, { scopes: 'write' })).rejects.toThrow( + 'refresh token validation failed', + ); + expect(spyRefresh).toHaveBeenCalledWith(validToken); + }); + + it('should accept when refreshSession returns session with sufficient scopes', async () => { + const spyRefresh = jest.spyOn(sdk, 'refresh').mockResolvedValueOnce({ + ok: true, + data: { sessionJwt: tokenScopeReadWrite }, + } as SdkResponse); + + await expect( + (sdk as any).refreshSession(validToken, { scopes: ['read', 'write'] }), + ).resolves.toHaveProperty('jwt', tokenScopeReadWrite); + expect(spyRefresh).toHaveBeenCalledWith(validToken); + }); + + it('should reject when validateAndRefreshSession refreshes to insufficient scopes', async () => { + const spyRefresh = jest.spyOn(sdk, 'refresh').mockResolvedValueOnce({ + ok: true, + data: { sessionJwt: tokenScopeReadOnly }, + } as SdkResponse); + + await expect( + (sdk as any).validateAndRefreshSession(expiredToken, validToken, { scopes: 'write' }), + ).rejects.toThrow('refresh token validation failed'); + expect(spyRefresh).toHaveBeenCalledWith(validToken); + }); + }); + describe('getKey', () => { it('should throw an error when key does not exist', async () => { await expect(sdk.getKey({ kid: 'unknown-key' } as JWTHeaderParameters)).rejects.toThrow( diff --git a/lib/index.ts b/lib/index.ts index cd8cace10..8819dd8b5 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,6 +12,7 @@ import { permissionsClaimName, refreshTokenCookieName, rolesClaimName, + scopeClaimName, sessionTokenCookieName, } from './constants'; import fetch from './fetch-polyfill'; @@ -173,7 +174,7 @@ const nodeSdk = ({ /** * Validate the given JWT with the right key and make sure the issuer is correct * @param jwt the JWT string to parse and validate - * @param options optional verification options (e.g., { audience }) + * @param options optional verification options (e.g., { audience, scopes }) * @returns AuthenticationInfo with the parsed token and JWT. Will throw an error if validation fails. */ async validateJwt(jwt: string, options?: VerifyOptions): Promise { @@ -193,6 +194,39 @@ const nodeSdk = ({ 'check_failed', ); } + + // Validate scopes if provided + if (options?.scopes) { + // Normalize required scopes to array + const requiredScopes = Array.isArray(options.scopes) ? options.scopes : [options.scopes]; + + // Extract scopes from token - support both "scope" (space-separated string) and "scopes" (array) + let tokenScopes: string[] = []; + const scopeClaim = token[scopeClaimName]; + const scopesClaim = token.scopes; + + if (typeof scopeClaim === 'string') { + // OAuth 2.0 standard: space-separated string + tokenScopes = scopeClaim.split(' ').filter((s) => s.length > 0); + } else if (Array.isArray(scopesClaim)) { + // Alternative: array of scopes + tokenScopes = scopesClaim.filter((s) => typeof s === 'string'); + } else if (Array.isArray(scopeClaim)) { + // Handle if "scope" claim is an array (non-standard but possible) + tokenScopes = scopeClaim.filter((s) => typeof s === 'string'); + } + + // Check if all required scopes are present in token scopes + const hasAllScopes = requiredScopes.every((scope) => tokenScopes.includes(scope)); + + if (!hasAllScopes) { + throw new errors.JWTClaimValidationFailed( + 'insufficient scopes', + scopeClaimName, + 'check_failed', + ); + } + } } return { jwt, token }; @@ -201,7 +235,7 @@ const nodeSdk = ({ /** * Validate an active session * @param sessionToken session JWT to validate - * @param options optional verification options (e.g., { audience }) + * @param options optional verification options (e.g., { audience, scopes }) * @returns AuthenticationInfo promise or throws Error if there is an issue with JWTs */ async validateSession( @@ -225,7 +259,7 @@ const nodeSdk = ({ * For session migration, use {@link sdk.refresh}. * * @param refreshToken refresh JWT to refresh the session with - * @param options optional verification options for the new session (e.g., { audience }) + * @param options optional verification options for the new session (e.g., { audience, scopes }) * @returns RefreshAuthenticationInfo promise or throws Error if there is an issue with JWTs */ async refreshSession( @@ -266,7 +300,7 @@ const nodeSdk = ({ * Validate session and refresh it if it expired * @param sessionToken session JWT * @param refreshToken refresh JWT - * @param options optional verification options (e.g., { audience }) used on validation and post-refresh + * @param options optional verification options (e.g., { audience, scopes }) used on validation and post-refresh * @returns RefreshAuthenticationInfo promise or throws Error if there is an issue with JWTs */ async validateAndRefreshSession( @@ -291,7 +325,7 @@ const nodeSdk = ({ * Exchange API key (access key) for a session key * @param accessKey access key to exchange for a session JWT * @param loginOptions Optional advanced controls over login parameters - * @param options optional verification options for the returned session (e.g., { audience }) + * @param options optional verification options for the returned session (e.g., { audience, scopes }) * @returns AuthenticationInfo with session JWT data */ async exchangeAccessKey( diff --git a/lib/types.ts b/lib/types.ts index 42e9251e3..e06f55d3e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -21,9 +21,10 @@ export interface RefreshAuthenticationInfo extends AuthenticationInfo { refreshJwt?: string; } -/** Options for token verification (extensible). For now only audience. */ +/** Options for token verification (extensible). For now only audience and scopes. */ export interface VerifyOptions { audience?: string | string[]; + scopes?: string | string[]; } /** Descope core SDK type */