Skip to content

Commit

Permalink
feat(step-up): add stepup validator (#176)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomerFrontegg authored Nov 28, 2023
1 parent 64466c9 commit 94559ae
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 3 deletions.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,20 @@ const userOrTenantEntity = await identityClient.validateToken(token, { withRoles
> entitlement decision-making, so remember to add option flag: `withRolesAndPermissions: true`.
(see <a href="#validating-jwt-manually">Validating JWT manually</a> 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);
Expand Down
3 changes: 3 additions & 0 deletions src/clients/identity/exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
7 changes: 7 additions & 0 deletions src/clients/identity/exceptions/max-age-exceeded.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { StatusCodeError } from './status-code-error.exception';

export class MaxAgeExceededException extends StatusCodeError {
constructor() {
super(401, 'Max age exceeded');
}
}
7 changes: 7 additions & 0 deletions src/clients/identity/exceptions/missing-acr.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { StatusCodeError } from './status-code-error.exception';

export class MissingAcrException extends StatusCodeError {
constructor(acr: string) {
super(401, `Missing ACR: ${acr}`);
}
}
7 changes: 7 additions & 0 deletions src/clients/identity/exceptions/missing-amr.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { StatusCodeError } from './status-code-error.exception';

export class MissingAmrException extends StatusCodeError {
constructor() {
super(401, `AMR is missing`);
}
}
100 changes: 99 additions & 1 deletion src/clients/identity/identity-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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',
Expand Down Expand Up @@ -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');
Expand All @@ -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 }) => {
Expand Down
1 change: 1 addition & 0 deletions src/clients/identity/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './identity-client';
export * from './step-up';
3 changes: 3 additions & 0 deletions src/clients/identity/step-up/constants.ts
Original file line number Diff line number Diff line change
@@ -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'];
3 changes: 3 additions & 0 deletions src/clients/identity/step-up/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './constants';
export * from './types';
export * from './step-up.validator';
70 changes: 70 additions & 0 deletions src/clients/identity/step-up/step-up.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
34 changes: 34 additions & 0 deletions src/clients/identity/step-up/step-up.validator.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
}
9 changes: 9 additions & 0 deletions src/clients/identity/step-up/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface ValidateStepupFields {
amr?: string[];
acr?: string;
auth_time?: number;
}

export interface IValidateStepupTokenOptions {
maxAge?: number;
}
Original file line number Diff line number Diff line change
@@ -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<IEntityWithRoles> {
Expand All @@ -17,6 +18,10 @@ export class AuthorizationJWTResolver extends TokenResolver<IEntityWithRoles> {
await this.validateRolesAndPermissions(entity, options);
}

if (entity.type === tokenTypes.UserToken && options?.stepUp) {
StepupValidator.validateStepUp(<IUser>entity, typeof options.stepUp === 'boolean' ? {} : options.stepUp);
}

return entity;
}

Expand Down
6 changes: 6 additions & 0 deletions src/clients/identity/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { IValidateStepupTokenOptions } from './step-up';

export enum AuthHeaderType {
JWT = 'JWT',
AccessToken = 'AccessToken',
Expand All @@ -12,6 +14,7 @@ export interface IValidateTokenOptions {
roles?: string[];
permissions?: string[];
withRolesAndPermissions?: boolean;
stepUp?: boolean | IValidateStepupTokenOptions;
}

export enum tokenTypes {
Expand Down Expand Up @@ -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 & {
Expand Down

0 comments on commit 94559ae

Please sign in to comment.