Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
missingArguments: 'E011002',
invalidRequest: 'E011003',
invalidArguments: 'E011004',
insufficientScopes: 'E011005',
wrongOTPCode: 'E061102',
tooManyOTPAttempts: 'E061103',
enchantedLinkPending: 'E062503',
Expand Down
116 changes: 116 additions & 0 deletions lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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'] },
Expand Down Expand Up @@ -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<JWTResponse>);

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<JWTResponse>);

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<JWTResponse>);

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(
Expand Down
44 changes: 39 additions & 5 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
permissionsClaimName,
refreshTokenCookieName,
rolesClaimName,
scopeClaimName,
sessionTokenCookieName,
} from './constants';
import fetch from './fetch-polyfill';
Expand Down Expand Up @@ -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<AuthenticationInfo> {
Expand All @@ -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];
Comment on lines +200 to +201

// 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',
);
Comment on lines +203 to +227
}
}
}

return { jwt, token };
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading