diff --git a/README.md b/README.md index a5eb56d..9905184 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,20 @@ const userOrTenantEntity = await identityClient.validateToken(token, { withRoles > entitlement decision-making, so remember to add option flag: `withRolesAndPermissions: true`. (see Validating JWT manually section for more details). - + +#### step-up +The client can be used to verify whether an authorized user has undergone step-up authentication. +> You can also require session max age to determine a stepped up user +```javascript +// Validate the token and decode its properties for a stepped-up user +const steppedUpUserEntity = await identityClient.validateToken(token, { stepUp: true }); + +// Validate the token with session maximum age requirement (up to one hour) for a stepped-up user +const steppedUpUserEntityWithMaxAge = await identityClient.validateToken(token, { stepUp: { maxAge: 3600 } }); +``` + +#### entitlements + When the user/tenant entity is resolved, you can start querying the entitlements engine: ```javascript const userEntitlementsClient = client.forUser(userOrTenantEntity); diff --git a/src/clients/identity/exceptions/index.ts b/src/clients/identity/exceptions/index.ts index 9cc6b43..12f35d0 100644 --- a/src/clients/identity/exceptions/index.ts +++ b/src/clients/identity/exceptions/index.ts @@ -2,3 +2,6 @@ export * from './failed-to-authenticate.exception'; export * from './insufficient-permission.exception'; export * from './insufficient-role.exception'; export * from './invalid-token-type.exception'; +export * from './max-age-exceeded.exception'; +export * from './missing-acr.exception'; +export * from './missing-amr.exception'; diff --git a/src/clients/identity/exceptions/max-age-exceeded.exception.ts b/src/clients/identity/exceptions/max-age-exceeded.exception.ts new file mode 100644 index 0000000..4856f1b --- /dev/null +++ b/src/clients/identity/exceptions/max-age-exceeded.exception.ts @@ -0,0 +1,7 @@ +import { StatusCodeError } from './status-code-error.exception'; + +export class MaxAgeExceededException extends StatusCodeError { + constructor() { + super(401, 'Max age exceeded'); + } +} diff --git a/src/clients/identity/exceptions/missing-acr.exception.ts b/src/clients/identity/exceptions/missing-acr.exception.ts new file mode 100644 index 0000000..1c43628 --- /dev/null +++ b/src/clients/identity/exceptions/missing-acr.exception.ts @@ -0,0 +1,7 @@ +import { StatusCodeError } from './status-code-error.exception'; + +export class MissingAcrException extends StatusCodeError { + constructor(acr: string) { + super(401, `Missing ACR: ${acr}`); + } +} diff --git a/src/clients/identity/exceptions/missing-amr.exception.ts b/src/clients/identity/exceptions/missing-amr.exception.ts new file mode 100644 index 0000000..ff681db --- /dev/null +++ b/src/clients/identity/exceptions/missing-amr.exception.ts @@ -0,0 +1,7 @@ +import { StatusCodeError } from './status-code-error.exception'; + +export class MissingAmrException extends StatusCodeError { + constructor() { + super(401, `AMR is missing`); + } +} diff --git a/src/clients/identity/identity-client.spec.ts b/src/clients/identity/identity-client.spec.ts index 87c5660..22083aa 100644 --- a/src/clients/identity/identity-client.spec.ts +++ b/src/clients/identity/identity-client.spec.ts @@ -2,8 +2,17 @@ import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import { config } from '../../config'; import { IdentityClient } from './identity-client'; -import { AuthHeaderType, ITenantAccessToken, IUser, IUserAccessToken, TEntityWithRoles, tokenTypes } from './types'; +import { + AuthHeaderType, + ITenantAccessToken, + IUser, + IUserAccessToken, + IUserApiToken, + TEntityWithRoles, + tokenTypes, +} from './types'; import { accessTokenHeaderResolver, authorizationHeaderResolver } from './token-resolvers'; +import { AMR_METHOD_VALUE, AMR_MFA_VALUE, STEP_UP_ACR_VALUE } from './step-up'; jest.setTimeout(60000); @@ -22,6 +31,19 @@ const fakeUser: IUser = { permissions: ['permission-key'], }; +const fakeUserApiToken: IUserApiToken = { + createdByUserId: 'fake-created-by-user-id', + sub: 'fake-sub', + tenantId: 'fake-tenant-id', + type: tokenTypes.UserApiToken, + userId: 'fake-user-id', + metadata: {}, + email: 'fake-email', + permissions: ['permission-key'], + roles: ['role-key'], + userMetadata: {}, +}; + const fakeUserAccessToken: IUserAccessToken = { sub: 'fake-sub', tenantId: 'fake-tenant-id', @@ -70,6 +92,7 @@ describe('Identity client', () => { try { //@ts-ignore await IdentityClient.getInstance().validateToken('fake-token', {}, 'invalid-header-type'); + fail('should throw'); } catch (e: any) { expect(e.statusCode).toEqual(401); expect(e.message).toEqual('Failed to verify authentication'); @@ -83,6 +106,81 @@ describe('Identity client', () => { expect(res).toEqual(fakeUser); }); + it('should not throw if stepup is required and token type is not user token', async () => { + //@ts-ignore + jest.spyOn(authorizationHeaderResolver, 'verifyAsync').mockImplementation(() => fakeUserApiToken); + const res = await IdentityClient.getInstance().validateToken( + 'fake-token', + { stepUp: { maxAge: 2 } }, + AuthHeaderType.JWT, + ); + expect(res).toEqual(fakeUserApiToken); + }); + + it('should throw if stepup is required and token has no amr value', async () => { + jest + //@ts-ignore + .spyOn(authorizationHeaderResolver, 'verifyAsync') + //@ts-ignore + .mockImplementation(() => ({ ...fakeUser, acr: STEP_UP_ACR_VALUE, amr: [] })); + try { + await IdentityClient.getInstance().validateToken('fake-token', { stepUp: true }, AuthHeaderType.JWT); + fail('should throw'); + } catch (e: any) { + expect(e.statusCode).toEqual(401); + expect(e.message).toEqual('AMR is missing'); + } + }); + + it('should throw if stepup is required and token has wrong acr value', async () => { + jest + //@ts-ignore + .spyOn(authorizationHeaderResolver, 'verifyAsync') + //@ts-ignore + .mockImplementation(() => ({ ...fakeUser, acr: 'not-stepup-acr' })); + try { + await IdentityClient.getInstance().validateToken('fake-token', { stepUp: {} }, AuthHeaderType.JWT); + fail('should throw'); + } catch (e: any) { + expect(e.statusCode).toEqual(401); + expect(e.message).toEqual('Missing ACR: http://schemas.openid.net/pape/policies/2007/06/multi-factor'); + } + }); + + it('should throw if stepup is required and maxAge exceeded', async () => { + //@ts-ignore + jest.spyOn(authorizationHeaderResolver, 'verifyAsync').mockImplementation(() => ({ + ...fakeUser, + acr: STEP_UP_ACR_VALUE, + amr: [AMR_MFA_VALUE, AMR_METHOD_VALUE[0]], + auth_time: Date.now() / 1000 - 20, + })); + try { + await IdentityClient.getInstance().validateToken('fake-token', { stepUp: { maxAge: 5 } }, AuthHeaderType.JWT); + fail('should throw'); + } catch (e: any) { + expect(e.statusCode).toEqual(401); + expect(e.message).toEqual('Max age exceeded'); + } + }); + + it('should not throw if stepup is required and maxAge is not exceeded', async () => { + const fakeSteppedUpUser = { + ...fakeUser, + acr: STEP_UP_ACR_VALUE, + amr: [AMR_MFA_VALUE, AMR_METHOD_VALUE[0]], + auth_time: Date.now() / 1000 - 20, + }; + //@ts-ignore + jest.spyOn(authorizationHeaderResolver, 'verifyAsync').mockImplementation(() => fakeSteppedUpUser); + const res = await IdentityClient.getInstance().validateToken( + 'fake-token', + { stepUp: { maxAge: 1000 } }, + AuthHeaderType.JWT, + ); + expect(res).toEqual(fakeSteppedUpUser); + }); + it.each([{ claims: fakeUserAccessToken }, { claims: fakeTenantAccessToken }])( 'should validate access token entity without fetching roles', async ({ claims }) => { diff --git a/src/clients/identity/index.ts b/src/clients/identity/index.ts index aa962f5..aa4e5f7 100644 --- a/src/clients/identity/index.ts +++ b/src/clients/identity/index.ts @@ -1 +1,2 @@ export * from './identity-client'; +export * from './step-up'; diff --git a/src/clients/identity/step-up/constants.ts b/src/clients/identity/step-up/constants.ts new file mode 100644 index 0000000..ffbbaf0 --- /dev/null +++ b/src/clients/identity/step-up/constants.ts @@ -0,0 +1,3 @@ +export const STEP_UP_ACR_VALUE = 'http://schemas.openid.net/pape/policies/2007/06/multi-factor'; +export const AMR_MFA_VALUE = 'mfa'; +export const AMR_METHOD_VALUE = ['otp', 'sms', 'hwk']; diff --git a/src/clients/identity/step-up/index.ts b/src/clients/identity/step-up/index.ts new file mode 100644 index 0000000..6722fe0 --- /dev/null +++ b/src/clients/identity/step-up/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './types'; +export * from './step-up.validator'; diff --git a/src/clients/identity/step-up/step-up.validator.spec.ts b/src/clients/identity/step-up/step-up.validator.spec.ts new file mode 100644 index 0000000..a56a17a --- /dev/null +++ b/src/clients/identity/step-up/step-up.validator.spec.ts @@ -0,0 +1,70 @@ +import { MaxAgeExceededException, MissingAcrException, MissingAmrException } from '../exceptions'; +import { AMR_METHOD_VALUE, AMR_MFA_VALUE, STEP_UP_ACR_VALUE } from './constants'; +import { StepupValidator } from './step-up.validator'; + +describe('StepUpValidator', () => { + describe('validateStepUp', () => { + it('should throw MissingAcrException if acr is not STEP_UP_ACR_VALUE', () => { + const dto = { acr: 'fake-acr' }; + + const act = () => StepupValidator.validateStepUp(dto, {}); + + expect(act).toThrow(MissingAcrException); + }); + + it('should throw MissingAmrException if amr does not include AMR_MFA_VALUE', () => { + const dto = { acr: STEP_UP_ACR_VALUE, amr: ['fake-amr'] }; + + const act = () => StepupValidator.validateStepUp(dto, {}); + + expect(act).toThrow(MissingAmrException); + }); + + it('should throw MissingAmrException if amr does not include AMR_METHOD_VALUE', () => { + const dto = { acr: STEP_UP_ACR_VALUE, amr: [AMR_MFA_VALUE] }; + const stepUpOptions = {}; + + const act = () => StepupValidator.validateStepUp(dto, stepUpOptions); + + expect(act).toThrow(MissingAmrException); + }); + + it('should throw MaxAgeExceededException if maxAge is exceeded', () => { + const dto = { + acr: STEP_UP_ACR_VALUE, + amr: [AMR_MFA_VALUE, AMR_METHOD_VALUE[0]], + auth_time: Date.now() / 1000 - 20, + }; + const stepUpOptions = { maxAge: 5 }; + + const act = () => StepupValidator.validateStepUp(dto, stepUpOptions); + + expect(act).toThrow(MaxAgeExceededException); + }); + + it('should throw MaxAgeExceededException if maxAge is specified but auth_time is missing', () => { + const dto = { + acr: STEP_UP_ACR_VALUE, + amr: [AMR_MFA_VALUE, AMR_METHOD_VALUE[0]], + }; + const stepUpOptions = { maxAge: 5 }; + + const act = () => StepupValidator.validateStepUp(dto, stepUpOptions); + + expect(act).toThrow(MaxAgeExceededException); + }); + + it('should not throw if acr is STEP_UP_ACR_VALUE, amr includes AMR_MFA_VALUE and AMR_METHOD_VALUE and maxAge is not exceeded', () => { + const dto = { + acr: STEP_UP_ACR_VALUE, + amr: [AMR_MFA_VALUE, AMR_METHOD_VALUE[0]], + auth_time: Date.now() / 1000 - 20, + }; + const stepUpOptions = { maxAge: 1000 }; + + const act = () => StepupValidator.validateStepUp(dto, stepUpOptions); + + expect(act).not.toThrow(); + }); + }); +}); diff --git a/src/clients/identity/step-up/step-up.validator.ts b/src/clients/identity/step-up/step-up.validator.ts new file mode 100644 index 0000000..085479a --- /dev/null +++ b/src/clients/identity/step-up/step-up.validator.ts @@ -0,0 +1,34 @@ +import { STEP_UP_ACR_VALUE, AMR_METHOD_VALUE, AMR_MFA_VALUE } from './constants'; +import { IValidateStepupTokenOptions, ValidateStepupFields } from './types'; +import { MaxAgeExceededException, MissingAcrException, MissingAmrException } from '../exceptions'; +import Logger from '../../../components/logger'; + +export class StepupValidator { + public static validateStepUp(dto: ValidateStepupFields, stepUpOptions: IValidateStepupTokenOptions = {}): void { + const { acr, amr } = dto; + const { maxAge } = stepUpOptions; + + const isACRValid = acr === STEP_UP_ACR_VALUE; + + if (!isACRValid) { + Logger.info('Invalid ACR', { acr: acr }); + throw new MissingAcrException(STEP_UP_ACR_VALUE); + } + + const isAMRIncludesMFA = amr?.includes(AMR_MFA_VALUE); + const isAMRIncludesMethod = AMR_METHOD_VALUE.find((method) => amr?.includes(method)); + + if (!(isAMRIncludesMFA && isAMRIncludesMethod)) { + Logger.info('Invalid AMR', { amr: amr }); + throw new MissingAmrException(); + } + + if (maxAge) { + const diff = Date.now() / 1000 - (dto.auth_time ?? 0); + if (diff > maxAge) { + Logger.info('Max age exceeded', { maxAge: maxAge, authTime: dto.auth_time }); + throw new MaxAgeExceededException(); + } + } + } +} diff --git a/src/clients/identity/step-up/types.ts b/src/clients/identity/step-up/types.ts new file mode 100644 index 0000000..8103c27 --- /dev/null +++ b/src/clients/identity/step-up/types.ts @@ -0,0 +1,9 @@ +export interface ValidateStepupFields { + amr?: string[]; + acr?: string; + auth_time?: number; +} + +export interface IValidateStepupTokenOptions { + maxAge?: number; +} diff --git a/src/clients/identity/token-resolvers/authorization-token-resolver.ts b/src/clients/identity/token-resolvers/authorization-token-resolver.ts index 7fdc2cc..8987580 100644 --- a/src/clients/identity/token-resolvers/authorization-token-resolver.ts +++ b/src/clients/identity/token-resolvers/authorization-token-resolver.ts @@ -1,4 +1,5 @@ -import { AuthHeaderType, IEntityWithRoles, IValidateTokenOptions, tokenTypes } from '../types'; +import { AuthHeaderType, IEntityWithRoles, IUser, IValidateTokenOptions, tokenTypes } from '../types'; +import { StepupValidator } from '../step-up/'; import { TokenResolver } from './token-resolver'; export class AuthorizationJWTResolver extends TokenResolver { @@ -17,6 +18,10 @@ export class AuthorizationJWTResolver extends TokenResolver { await this.validateRolesAndPermissions(entity, options); } + if (entity.type === tokenTypes.UserToken && options?.stepUp) { + StepupValidator.validateStepUp(entity, typeof options.stepUp === 'boolean' ? {} : options.stepUp); + } + return entity; } diff --git a/src/clients/identity/types.ts b/src/clients/identity/types.ts index e2d5189..e6317ae 100644 --- a/src/clients/identity/types.ts +++ b/src/clients/identity/types.ts @@ -1,3 +1,5 @@ +import { IValidateStepupTokenOptions } from './step-up'; + export enum AuthHeaderType { JWT = 'JWT', AccessToken = 'AccessToken', @@ -12,6 +14,7 @@ export interface IValidateTokenOptions { roles?: string[]; permissions?: string[]; withRolesAndPermissions?: boolean; + stepUp?: boolean | IValidateStepupTokenOptions; } export enum tokenTypes { @@ -57,6 +60,9 @@ export type IUser = IEntityWithRoles & { tenantIds?: string[]; profilePictureUrl?: string; superUser?: true; + amr?: string[]; + acr?: string; + auth_time?: number; }; export type IApiToken = IEntityWithRoles & {