Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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