Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 scopes claims in the claims map */
export const scopesClaimName = 'scopes';
34 changes: 33 additions & 1 deletion lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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));
}
62 changes: 51 additions & 11 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
permissionsClaimName,
refreshTokenCookieName,
rolesClaimName,
scopesClaimName,
sessionTokenCookieName,
} from './constants';
import fetch from './fetch-polyfill';
Expand All @@ -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;
Expand Down Expand Up @@ -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<AuthenticationInfo> {
async validateJwt(jwt: string, options?: VerifyOptions): Promise<AuthenticationInfo> {
// 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<string, unknown> = { clockTolerance: 5 };
if (options?.audience) verifyOptions.audience = options.audience;
const res = await jwtVerify(jwt, sdk.getKey, verifyOptions);
const token = res.payload;

if (token) {
Expand All @@ -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<AuthenticationInfo> {
async validateSession(
sessionToken: string,
options?: VerifyOptions,
): Promise<AuthenticationInfo> {
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 */
Expand All @@ -206,22 +216,26 @@ 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<RefreshAuthenticationInfo> {
async refreshSession(
refreshToken: string,
options?: VerifyOptions,
): Promise<RefreshAuthenticationInfo> {
if (!refreshToken) throw Error('refresh token is required to refresh a session');

try {
await sdk.validateJwt(refreshToken);
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) {
Expand All @@ -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<RefreshAuthenticationInfo> {
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<AuthenticationInfo> {
if (!accessKey) throw Error('access key must not be empty');

Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -460,3 +499,4 @@ export type {
SdkResponse,
} from '@descope/core-js-sdk';
export type { AuthenticationInfo };
export type { VerifyOptions } from './types';
6 changes: 6 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface Token {
sub?: string;
exp?: number;
iss?: string;
scopes?: string[];
[claim: string]: unknown;
}

Expand All @@ -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<Parameters<CreateCoreSdk>>;
Expand Down
Loading