From 74b1507a71af4602eb77cdebdadaa24d581b3d69 Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Mon, 6 Oct 2025 08:56:55 -0700 Subject: [PATCH] added scopes to AuthenticationInfo interface --- lib/constants.ts | 2 ++ lib/helpers.ts | 34 +++++++++++++++++++++++++- lib/index.ts | 62 +++++++++++++++++++++++++++++++++++++++--------- lib/types.ts | 6 +++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index ea7e93bbcc..52c46ec028 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 scopes claims in the claims map */ +export const scopesClaimName = 'scopes'; diff --git a/lib/helpers.ts b/lib/helpers.ts index e4b8d998ae..0d5584ec84 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -1,5 +1,5 @@ import type { SdkFnWrapper } from '@descope/core-js-sdk'; -import { authorizedTenantsClaimName, refreshTokenCookieName } from './constants'; +import { authorizedTenantsClaimName, refreshTokenCookieName, scopesClaimName } from './constants'; import { AuthenticationInfo } from './types'; /** @@ -86,3 +86,35 @@ export function getAuthorizationClaimItems( export function isUserAssociatedWithTenant(authInfo: AuthenticationInfo, tenant: string): boolean { return !!authInfo.token[authorizedTenantsClaimName]?.[tenant]; } + +/** + * Get the scopes from the JWT token + * @param authInfo The parsed authentication info from the JWT + * @returns the scopes from the top-level claim + */ +export function getScopes(authInfo: AuthenticationInfo): string[] { + const value = authInfo.token[scopesClaimName]; + return Array.isArray(value) ? value : []; +} + +/** + * Check if the user has all the required scopes + * @param authInfo The parsed authentication info from the JWT + * @param scopes list of scopes to check for + * @returns true if user has all required scopes, false otherwise + */ +export function hasScopes(authInfo: AuthenticationInfo, scopes: string[]): boolean { + const userScopes = getScopes(authInfo); + return scopes.every((scope) => userScopes.includes(scope)); +} + +/** + * Get the scopes that match the required scopes + * @param authInfo The parsed authentication info from the JWT + * @param scopes list of scopes to match against + * @returns array of scopes that match the required scopes + */ +export function getMatchedScopes(authInfo: AuthenticationInfo, scopes: string[]): string[] { + const userScopes = getScopes(authInfo); + return scopes.filter((scope) => userScopes.includes(scope)); +} diff --git a/lib/index.ts b/lib/index.ts index a458f6ba28..7c4d1ed6ec 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,6 +12,7 @@ import { permissionsClaimName, refreshTokenCookieName, rolesClaimName, + scopesClaimName, sessionTokenCookieName, } from './constants'; import fetch from './fetch-polyfill'; @@ -20,9 +21,11 @@ import { getCookieValue, isUserAssociatedWithTenant, withCookie, + hasScopes, + getMatchedScopes, } from './helpers'; import withManagement from './management'; -import { AuthenticationInfo, RefreshAuthenticationInfo } from './types'; +import { AuthenticationInfo, RefreshAuthenticationInfo, VerifyOptions } from './types'; import descopeErrors from './errors'; declare const BUILD_VERSION: string; @@ -161,11 +164,14 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod /** * 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 }) * @returns AuthenticationInfo with the parsed token and JWT. Will throw an error if validation fails. */ - async validateJwt(jwt: string): Promise { + async validateJwt(jwt: string, options?: VerifyOptions): Promise { // Do not hard-code the algo because library does not support `None` so all are valid - const res = await jwtVerify(jwt, sdk.getKey, { clockTolerance: 5 }); + const verifyOptions: Record = { clockTolerance: 5 }; + if (options?.audience) verifyOptions.audience = options.audience; + const res = await jwtVerify(jwt, sdk.getKey, verifyOptions); const token = res.payload; if (token) { @@ -186,13 +192,17 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod /** * Validate an active session * @param sessionToken session JWT to validate + * @param options optional verification options (e.g., { audience }) * @returns AuthenticationInfo promise or throws Error if there is an issue with JWTs */ - async validateSession(sessionToken: string): Promise { + async validateSession( + sessionToken: string, + options?: VerifyOptions, + ): Promise { if (!sessionToken) throw Error('session token is required for validation'); try { - const token = await sdk.validateJwt(sessionToken); + const token = await sdk.validateJwt(sessionToken, options); return token; } catch (error) { /* istanbul ignore next */ @@ -206,9 +216,13 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod * 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 }) * @returns RefreshAuthenticationInfo promise or throws Error if there is an issue with JWTs */ - async refreshSession(refreshToken: string): Promise { + async refreshSession( + refreshToken: string, + options?: VerifyOptions, + ): Promise { if (!refreshToken) throw Error('refresh token is required to refresh a session'); try { @@ -216,12 +230,12 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod const jwtResp = await sdk.refresh(refreshToken); if (jwtResp.ok) { // if refresh was successful, validate the new session JWT - const seesionJwt = + const sessionJwt = getCookieValue( (jwtResp.data as JWTResponseWithCookies)?.cookies?.join(';'), sessionTokenCookieName, ) || jwtResp.data?.sessionJwt; - const token = await sdk.validateJwt(seesionJwt); + const token = await sdk.validateJwt(sessionJwt, options); // add cookies to the token response if they exist token.cookies = (jwtResp.data as JWTResponseWithCookies)?.cookies || []; if (jwtResp.data?.refreshJwt) { @@ -243,34 +257,38 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod * 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 * @returns RefreshAuthenticationInfo promise or throws Error if there is an issue with JWTs */ async validateAndRefreshSession( sessionToken?: string, refreshToken?: string, + options?: VerifyOptions, ): Promise { if (!sessionToken && !refreshToken) throw Error('both session and refresh tokens are empty'); try { - const token = await sdk.validateSession(sessionToken); + const token = await sdk.validateSession(sessionToken, options); return token; } catch (error) { /* istanbul ignore next */ logger?.log(`session validation failed with error ${error} - trying to refresh it`); } - return sdk.refreshSession(refreshToken); + return sdk.refreshSession(refreshToken, options); }, /** * 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 }) * @returns AuthenticationInfo with session JWT data */ async exchangeAccessKey( accessKey: string, loginOptions?: AccessKeyLoginOptions, + options?: VerifyOptions, ): Promise { if (!accessKey) throw Error('access key must not be empty'); @@ -294,7 +312,7 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod } try { - const token = await sdk.validateJwt(sessionJwt); + const token = await sdk.validateJwt(sessionJwt, options); return token; } catch (error) { logger?.error('failed to parse jwt from access key', error); @@ -407,6 +425,26 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod const membership = getAuthorizationClaimItems(authInfo, rolesClaimName, tenant); return roles.filter((role) => membership.includes(role)); }, + + /** + * Make sure that all given scopes exist on the parsed JWT top level claims + * @param authInfo JWT parsed info + * @param scopes list of scopes to make sure they exist on the JWT claims + * @returns true if all scopes exist, false otherwise + */ + validateScopes(authInfo: AuthenticationInfo, scopes: string[]): boolean { + return hasScopes(authInfo, scopes); + }, + + /** + * Retrieves the scopes from JWT top level claims that match the specified scopes list + * @param authInfo JWT parsed info containing the scopes + * @param scopes List of scopes to match against the JWT claims + * @returns An array of scopes that are both in the JWT claims and the specified list. Returns an empty array if no matches are found + */ + getMatchedScopes(authInfo: AuthenticationInfo, scopes: string[]): string[] { + return getMatchedScopes(authInfo, scopes); + }, }; return wrapWith( @@ -449,6 +487,7 @@ const nodeSdk = ({ authManagementKey, managementKey, publicKey, ...config }: Nod nodeSdk.RefreshTokenCookieName = refreshTokenCookieName; nodeSdk.SessionTokenCookieName = sessionTokenCookieName; +nodeSdk.ScopesClaimName = scopesClaimName; nodeSdk.DescopeErrors = descopeErrors; export default nodeSdk; @@ -460,3 +499,4 @@ export type { SdkResponse, } from '@descope/core-js-sdk'; export type { AuthenticationInfo }; +export type { VerifyOptions } from './types'; diff --git a/lib/types.ts b/lib/types.ts index 305194d0e9..a134f1f78c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -7,6 +7,7 @@ interface Token { sub?: string; exp?: number; iss?: string; + scopes?: string[]; [claim: string]: unknown; } @@ -21,6 +22,11 @@ export interface RefreshAuthenticationInfo extends AuthenticationInfo { refreshJwt?: string; } +/** Options for token verification (extensible). For now only audience. */ +export interface VerifyOptions { + audience?: string | string[]; +} + /** Descope core SDK type */ export type CreateCoreSdk = typeof createSdk; export type CoreSdkConfig = Head>;