Skip to content

Commit

Permalink
refactor(auth): rejects malformed tokens (#597)
Browse files Browse the repository at this point in the history
`requireValidToken` option is set to false by default so that there is no breaking change.
 
BREAKING CHANGE:
`failWhenNoToken` has been removed from password strategy as it was still not released and becoming redundant

Closes #517
  • Loading branch information
alain-charles authored and nnixaa committed Aug 14, 2018
1 parent 0370abe commit 127b5d2
Show file tree
Hide file tree
Showing 13 changed files with 330 additions and 265 deletions.
44 changes: 19 additions & 25 deletions src/framework/auth/services/token/token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ describe('auth token', () => {
// tslint:disable
const simpleToken = new NbAuthSimpleToken('token','strategy');
const validJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjZXJlbWEuZnIiLCJpYXQiOjE1MzIzNTA4MDAsImV4cCI6MjUzMjM1MDgwMCwic3ViIjoiQWxhaW4gQ0hBUkxFUyIsImFkbWluIjp0cnVlfQ.Rgkgb4KvxY2wp2niXIyLJNJeapFp9z3tCF-zK6Omc8c', 'strategy');
const emptyJWTToken = new NbAuthJWTToken('..', 'strategy');
const invalidBase64JWTToken = new NbAuthJWTToken('h%2BHY.h%2BHY.h%2BHY','strategy');

const invalidJWTToken = new NbAuthJWTToken('.','strategy');

const noIatJWTToken = new NbAuthJWTToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjZXJlbWEuZnIiLCJleHAiOjE1MzI0MzcyMDAsInN1YiI6IkFsYWluIENIQVJMRVMiLCJhZG1pbiI6dHJ1ZX0.cfwQlKo6xomXkE-U-SOqse2GjdxncOuhdd1VWIOiYzA', 'strategy');

Expand All @@ -27,36 +23,37 @@ describe('auth token', () => {

// tslint:enable

it('getPayload success', () => {
expect(validJWTToken.getPayload())
// tslint:disable-next-line
.toEqual(JSON.parse('{"iss":"cerema.fr","iat":1532350800,"exp":2532350800,"sub":"Alain CHARLES","admin":true}'));
});

it('getPayload, not valid JWT token, must consist of three parts', () => {
it('JWT Token constructor, not valid JWT token, must consist of three parts', () => {
expect(() => {
invalidJWTToken.getPayload();
new NbAuthJWTToken('.', 'strategy');
})
.toThrow(new Error(
`The payload ${invalidJWTToken.getValue()} is not valid JWT payload and must consist of three parts.`));
`The payload . is not valid JWT payload and must consist of three parts.`));
});

it('getPayload, not valid JWT token, cannot be decoded', () => {
it('JWT Token constructor,, not valid JWT token, cannot be decoded', () => {
expect(() => {
emptyJWTToken.getPayload();
new NbAuthJWTToken('..', 'strategy');
})
.toThrow(new Error(
`The payload ${emptyJWTToken.getValue()} is not valid JWT payload and cannot be decoded.`));
`The payload .. is not valid JWT payload and cannot be decoded.`));
});

it('getPayload, not valid base64 in JWT token, cannot be decoded', () => {
expect(() => {
invalidBase64JWTToken.getPayload();
new NbAuthJWTToken('h%2BHY.h%2BHY.h%2BHY', 'strategy');
})
.toThrow(new Error(
`The payload ${invalidBase64JWTToken.getValue()} is not valid JWT payload and cannot be parsed.`));
`The payload h%2BHY.h%2BHY.h%2BHY is not valid JWT payload and cannot be parsed.`));
});

it('getPayload success', () => {
expect(validJWTToken.getPayload())
// tslint:disable-next-line
.toEqual(JSON.parse('{"iss":"cerema.fr","iat":1532350800,"exp":2532350800,"sub":"Alain CHARLES","admin":true}'));
});


it('getCreatedAt success : now for simpleToken', () => {
// we consider dates are the same if differing from minus than 10 ms
expect(simpleToken.getCreatedAt().getTime() - now.getTime() < 10);
Expand Down Expand Up @@ -166,8 +163,6 @@ describe('auth token', () => {

let validToken = new NbAuthOAuth2Token(token, 'strategy');

const emptyToken = new NbAuthOAuth2Token({}, 'strategy');

const noExpToken = new NbAuthOAuth2Token({
access_token: '2YotnFZFEjr1zCsicMWpAA',
refresh_token: 'tGzv3JOkF0XG5Qx2TlKWIA',
Expand All @@ -178,9 +173,9 @@ describe('auth token', () => {
expect(validToken.getPayload()).toEqual(token);
});

it('getPayload, not valid token, cannot be decoded', () => {
it('empty token constructor, not valid token, cannot be decoded', () => {
expect(() => {
emptyToken.getPayload();
new NbAuthOAuth2Token({}, 'strategy');
})
.toThrow(new Error(
`Cannot extract payload from an empty token.`));
Expand Down Expand Up @@ -273,7 +268,6 @@ describe('auth token', () => {

const validToken = new NbAuthOAuth2JWTToken(validPayload, 'strategy');
let noExpButIatToken = new NbAuthOAuth2JWTToken(noExpButIatPayload, 'strategy');
const emptyToken = new NbAuthOAuth2JWTToken({}, 'strategy');
const permanentToken = new NbAuthOAuth2JWTToken(permanentPayload, 'strategy');

it('getPayload success', () => {
Expand All @@ -284,9 +278,9 @@ describe('auth token', () => {
expect(validToken.getAccessTokenPayload()).toEqual(accessTokenPayload);
});

it('getPayload, not valid token, cannot be decoded', () => {
it('empty token constructor, not valid token, cannot be decoded', () => {
expect(() => {
emptyToken.getPayload();
new NbAuthOAuth2JWTToken({}, 'strategy');
})
.toThrow(new Error(
`Cannot extract payload from an empty token.`));
Expand Down
147 changes: 104 additions & 43 deletions src/framework/auth/services/token/token.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { urlBase64Decode } from '../../helpers';

export abstract class NbAuthToken {

protected payload: any = null;

abstract getValue(): string;
abstract isValid(): boolean;
abstract getPayload(): string;
// the strategy name used to acquire this token (needed for refreshing token)
abstract getOwnerStrategyName(): string;
abstract getCreatedAt(): Date;
Expand All @@ -12,6 +14,38 @@ export abstract class NbAuthToken {
getName(): string {
return (this.constructor as NbAuthTokenClass).NAME;
}

getPayload(): any {
return this.payload;
}
}

export class NbAuthTokenNotFoundError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class NbAuthIllegalTokenError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class NbAuthEmptyTokenError extends NbAuthIllegalTokenError {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class NbAuthIllegalJWTTokenError extends NbAuthIllegalTokenError {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}

export interface NbAuthRefreshableToken {
Expand All @@ -31,29 +65,31 @@ export function nbAuthCreateToken<T extends NbAuthToken>(tokenClass: NbAuthToken
return new tokenClass(token, ownerStrategyName, createdAt);
}

export function decodeJwtPayload(payload: string): string {
export function decodeJwtPayload(payload: string): any {

if (!payload) {
throw new Error('Cannot extract payload from an empty token.');
if (payload.length === 0) {
throw new NbAuthEmptyTokenError('Cannot extract from an empty payload.');
}

const parts = payload.split('.');

if (parts.length !== 3) {
throw new Error(`The payload ${payload} is not valid JWT payload and must consist of three parts.`);
throw new NbAuthIllegalJWTTokenError(
`The payload ${payload} is not valid JWT payload and must consist of three parts.`);
}

let decoded;
try {
decoded = urlBase64Decode(parts[1]);
} catch (e) {
throw new Error(`The payload ${payload} is not valid JWT payload and cannot be parsed.`);
throw new NbAuthIllegalJWTTokenError(
`The payload ${payload} is not valid JWT payload and cannot be parsed.`);
}

if (!decoded) {
throw new Error(`The payload ${payload} is not valid JWT payload and cannot be decoded.`);
throw new NbAuthIllegalJWTTokenError(
`The payload ${payload} is not valid JWT payload and cannot be decoded.`);
}

return JSON.parse(decoded);
}

Expand All @@ -68,9 +104,21 @@ export class NbAuthSimpleToken extends NbAuthToken {
protected readonly ownerStrategyName: string,
protected createdAt?: Date) {
super();
try {
this.parsePayload();
} catch (err) {
if (!(err instanceof NbAuthTokenNotFoundError)) {
// token is present but has got a problem, including illegal
throw err;
}
}
this.createdAt = this.prepareCreatedAt(createdAt);
}

protected parsePayload(): any {
this.payload = null;
}

protected prepareCreatedAt(date: Date) {
return date ? date : new Date();
}
Expand All @@ -95,10 +143,6 @@ export class NbAuthSimpleToken extends NbAuthToken {
return this.ownerStrategyName;
}

getPayload(): string {
return null;
}

/**
* Is non empty and valid
* @returns {boolean}
Expand Down Expand Up @@ -127,21 +171,19 @@ export class NbAuthJWTToken extends NbAuthSimpleToken {
* for JWT token, the iat (issued at) field of the token payload contains the creation Date
*/
protected prepareCreatedAt(date: Date) {
let decoded;
try {
decoded = this.getPayload();
}
finally {
const decoded = this.getPayload();
return decoded && decoded.iat ? new Date(Number(decoded.iat) * 1000) : super.prepareCreatedAt(date);
}
}

/**
* Returns payload object
* @returns any
*/
getPayload(): any {
return decodeJwtPayload(this.token);
protected parsePayload(): void {
if (!this.token) {
throw new NbAuthTokenNotFoundError('Token not found. ')
}
this.payload = decodeJwtPayload(this.token);
}

/**
Expand All @@ -150,7 +192,7 @@ export class NbAuthJWTToken extends NbAuthSimpleToken {
*/
getTokenExpDate(): Date {
const decoded = this.getPayload();
if (!decoded.hasOwnProperty('exp')) {
if (decoded && !decoded.hasOwnProperty('exp')) {
return null;
}
const date = new Date(0);
Expand Down Expand Up @@ -216,15 +258,18 @@ export class NbAuthOAuth2Token extends NbAuthSimpleToken {
}

/**
* Returns token payload
* Parses token payload
* @returns any
*/
getPayload(): any {
if (!this.token || !Object.keys(this.token).length) {
throw new Error('Cannot extract payload from an empty token.');
protected parsePayload(): void {
if (!this.token) {
throw new NbAuthTokenNotFoundError('Token not found.')
} else {
if (!Object.keys(this.token).length) {
throw new NbAuthEmptyTokenError('Cannot extract payload from an empty token.');
}
}

return this.token;
this.payload = this.token;
}

/**
Expand Down Expand Up @@ -264,32 +309,49 @@ export class NbAuthOAuth2Token extends NbAuthSimpleToken {
}

/**
* Wrapper for OAuth2 token
* Wrapper for OAuth2 token embedding JWT tokens
*/
export class NbAuthOAuth2JWTToken extends NbAuthOAuth2Token {

static NAME = 'nb:auth:oauth2:jwt:token';

/**
* for Oauth2 JWT token, the iat (issued at) field of the access_token payload
*/
protected prepareCreatedAt(date: Date) {
let decoded;
try {
decoded = this.getAccessTokenPayload();
}
finally {
return decoded && decoded.iat ? new Date(Number(decoded.iat) * 1000) : super.prepareCreatedAt(date);
}
protected accessTokenPayload: any;

protected parsePayload(): void {
super.parsePayload();
this.parseAccessTokenPayload();
}

protected parseAccessTokenPayload(): any {
const accessToken = this.getValue();
if (!accessToken) {
throw new NbAuthTokenNotFoundError('access_token key not found.')
}
this.accessTokenPayload = decodeJwtPayload(accessToken);
}

/**
* Returns access token payload
* @returns any
*/
getAccessTokenPayload(): any {
return decodeJwtPayload(this.getValue())
return this.accessTokenPayload;
}

/**
* for Oauth2 JWT token, the iat (issued at) field of the access_token payload
*/
protected prepareCreatedAt(date: Date) {
const payload = this.accessTokenPayload;
return payload && payload.iat ? new Date(Number(payload.iat) * 1000) : super.prepareCreatedAt(date);
}

/**
* Is token valid
* @returns {boolean}
*/
isValid(): boolean {
return this.accessTokenPayload && super.isValid();
}

/**
Expand All @@ -299,10 +361,9 @@ export class NbAuthOAuth2JWTToken extends NbAuthOAuth2Token {
* @returns Date
*/
getTokenExpDate(): Date {
const accessTokenPayload = this.getAccessTokenPayload();
if (accessTokenPayload.hasOwnProperty('exp')) {
if (this.accessTokenPayload && this.accessTokenPayload.hasOwnProperty('exp')) {
const date = new Date(0);
date.setUTCSeconds(accessTokenPayload.exp);
date.setUTCSeconds(this.accessTokenPayload.exp);
return date;
} else {
return super.getTokenExpDate();
Expand Down
18 changes: 15 additions & 3 deletions src/framework/auth/strategies/auth-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { Observable } from 'rxjs';
import { NbAuthResult } from '../services/auth-result';
import { NbAuthStrategyOptions } from './auth-strategy-options';
import { deepExtend, getDeepFromObject } from '../helpers';
import { NbAuthToken, nbAuthCreateToken } from '../services/token/token';
import {
NbAuthToken,
nbAuthCreateToken,
NbAuthIllegalTokenError,
} from '../services/token/token';

export abstract class NbAuthStrategy {

Expand All @@ -20,8 +24,16 @@ export abstract class NbAuthStrategy {
return getDeepFromObject(this.options, key, null);
}

createToken<T extends NbAuthToken>(value: any): T {
return nbAuthCreateToken<T>(this.getOption('token.class'), value, this.getName());
createToken<T extends NbAuthToken>(value: any, failWhenInvalidToken?: boolean): T {
const token = nbAuthCreateToken<T>(this.getOption('token.class'), value, this.getName());
// At this point, nbAuthCreateToken failed with NbAuthIllegalTokenError which MUST be intercepted by strategies
// Or token is created. It MAY be created even if backend did not return any token, in this case it is !Valid
if (failWhenInvalidToken && !token.isValid()) {
// If we require a valid token (i.e. isValid), then we MUST throw NbAuthIllegalTokenError so that the strategies
// intercept it
throw new NbAuthIllegalTokenError('Token is empty or invalid.');
}
return token;
}

getName(): string {
Expand Down
Loading

0 comments on commit 127b5d2

Please sign in to comment.