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 & {