nomatic-jwt
Version:
JSON Web Token (JWT) utilities for Node.js
358 lines (287 loc) • 10.3 kB
text/typescript
import * as crypto from 'crypto';
import * as CryptoJS from 'crypto-js';
import * as base64 from './base64';
import {JWTError, JWTExpiredError, JWTSignatureError} from './errors';
export type JWTAlgorithm = 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
export type JWTClaims = JWTRegisteredClaims & JWTPrivateClaims;
export type JWTPayloadData = JWTClaims | string;
export interface JWTHeaderData {
typ: string;
alg: string;
}
export interface JWTOptions {
algorithm?: JWTAlgorithm;
autoValidate?: boolean;
expiresIn?: number; // In seconds
timeOffset?: number; // In seconds
key?: string;
privateKey?: string;
publicKey?: string;
}
export interface JWTData {
header: JWTHeaderData;
payload: JWTPayloadData;
signature: string;
}
export interface JWTRegisteredClaims {
/**
* Issuer (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.1)
*/
iss?: any;
/**
* Subject (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.2)
*/
sub?: any;
/**
* Audience (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.3)
*/
aud?: any;
/**
* Expiration Time (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.4)
*/
exp?: number;
/**
* Not Before (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.5)
*/
nbf?: number;
/**
* Issued At (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.6)
*/
iat?: number; // In seconds
/**
* JWT ID (registered): RFC-7519 (https://tools.ietf.org/html/rfc7519#section-4.1.7)
*/
jid?: any;
}
export interface JWTPrivateClaims {
[key: string]: string | number | boolean | Object | any[];
}
export class JWT {
private _algorithm: JWTAlgorithm;
private _autoValidate: boolean;
private _expiresIn: number;
private _key: string;
private _privateKey: string;
private _publicKey: string;
private _timeOffset: number;
constructor (options: JWTOptions = {}) {
this.algorithm = options.algorithm || 'HS256';
this.expiresIn = options.expiresIn || (60 * 60);
this.autoValidate = options.autoValidate || true;
if (this.algorithm.startsWith('HS')) {
this.key = options.key || crypto.randomBytes(128).toString('hex');
} else if (options.privateKey && options.publicKey) {
this.privateKey = options.privateKey;
this.publicKey = options.publicKey;
} else {
throw new JWTError(`'privateKey' and 'publicKey' must be specified with ${this.algorithm} algorithm`);
}
this.timeOffset = options.timeOffset || 60;
}
public static parsePayload(payload: string): JWTPayloadData {
try {
return JSON.parse(payload);
} catch (error) {
if (error.name !== 'SyntaxError') {
throw error;
} else {
return payload;
}
}
}
public get algorithm() {
return this._algorithm;
}
public set algorithm(algorithm: JWTAlgorithm) {
if (algorithm.startsWith('HS') || algorithm.startsWith('RS')) {
if (algorithm.endsWith('256') || algorithm.endsWith('384') || algorithm.endsWith('512')) {
this._algorithm = algorithm;
return;
}
}
throw new JWTError(`Invalid algorithm: ${algorithm}`);
}
public get autoValidate() {
return this._autoValidate;
}
public set autoValidate(autoValidate: boolean) {
this._autoValidate = autoValidate;
}
public get expiresIn() {
return this._expiresIn;
}
public set expiresIn(expiresIn: number) {
if (typeof expiresIn === 'number') {
this._expiresIn = expiresIn;
return;
}
throw new JWTError('\'expiresIn\' must be a number');
}
public get key() {
return this._key;
}
public set key(key: string) {
if (typeof key === 'string') {
this._key = key;
return;
}
throw new JWTError('\'key\' must be a string');
}
public get privateKey() {
return this._privateKey;
}
public set privateKey(privateKey: string) {
if (typeof privateKey === 'string') {
this._privateKey = privateKey;
return;
}
throw new JWTError('\'privateKey\' must be a string');
}
public get publicKey() {
return this._publicKey;
}
public set publicKey(publicKey: string) {
if (typeof publicKey === 'string') {
this._publicKey = publicKey;
return;
}
throw new JWTError('\'publicKey\' must be a string');
}
public get timeOffset() {
return this._timeOffset;
}
public set timeOffset(timeOffset: number) {
if (typeof timeOffset === 'number') {
this._timeOffset = timeOffset;
return;
}
throw new JWTError('\'timeOffset\' must be a number');
}
public signRaw(data: string, key: string = null, algorithm: JWTAlgorithm = this.algorithm): string {
if (!key) {
if (algorithm.startsWith('RS')) {
key = this.privateKey;
} else if (algorithm.startsWith('HS')) {
key = this.key;
}
}
let signature: string;
if (algorithm.startsWith('RS')) {
signature = crypto.createSign('RSA-SHA' + algorithm.substr(2))
.update(data)
.sign(key, 'base64');
} else if (algorithm.startsWith('HS')) {
signature = CryptoJS
.enc
.Base64
.stringify(CryptoJS['HmacSHA' + algorithm.substr(2)](data, key));
} else {
throw new Error('Unknown or unsupported algorithm: ' + algorithm);
}
return base64.escape(signature);
}
public verifyRaw(data: string, signature: string, key: string = null, algorithm: JWTAlgorithm = this.algorithm): boolean {
if (algorithm.startsWith('HS')) {
if (!key) {
key = this.key;
}
return signature === this.signRaw(data, key, algorithm);
} else if (algorithm.startsWith('RS')) {
if (!key) {
key = this.publicKey;
}
return crypto.createVerify('RSA-SHA' + algorithm.substr(2))
.update(data)
.verify(key, base64.unescape(signature), 'base64');
} else {
throw new Error('Unknown or unsupported algorithm: ' + algorithm);
}
}
public decode(encoded: string, key: string = null, algorithm: JWTAlgorithm = this.algorithm): JWTData {
if (!key) {
if (algorithm.startsWith('RS')) {
key = this.publicKey;
} else if (algorithm.startsWith('HS')) {
key = this.key;
}
}
const encodedParts = encoded.split('.');
if (encodedParts.length !== 3) {
throw new Error('Invalid number of encoded parts: ' + encoded.length);
}
const token: JWTData = {
header: JSON.parse(base64.decodeSafe(encodedParts[0])),
payload: JWT.parsePayload(base64.decodeSafe(encodedParts[1])),
signature: encodedParts[2]
};
if (this.autoValidate) {
return this.validate(token, key, algorithm);
} else {
return token;
}
}
public encode(payload: JWTPayloadData, key: string = null, algorithm: JWTAlgorithm = this.algorithm): string {
if (!key) {
if (algorithm.startsWith('RS')) {
key = this.privateKey;
} else if (algorithm.startsWith('HS')) {
key = this.key;
}
}
const header: JWTHeaderData = {
typ: 'JWT',
alg: algorithm
};
if (this.expiresIn && !(payload instanceof String) && !payload.exp) {
const current = Math.floor(( new Date().getTime() / 1000));
payload.exp = current + this.expiresIn;
if (!payload.nbf) {
payload.nbf = current;
}
if (!payload.iat) {
payload.iat = current;
}
}
const encoded = [];
encoded.push(base64.encodeSafe(JSON.stringify(header)));
encoded.push(base64.encodeSafe(JSON.stringify(payload)));
encoded.push(this.signRaw(encoded.join('.'), key, algorithm));
return encoded.join('.');
}
public validate(token: JWTData, key: string = null, algorithm: JWTAlgorithm = this.algorithm): JWTData {
if (!key) {
if (algorithm.startsWith('RS')) {
key = this.publicKey;
} else if (algorithm.startsWith('HS')) {
key = this.key;
}
}
const encoded = [];
encoded.push(base64.encodeSafe(JSON.stringify(token.header)));
encoded.push(base64.encodeSafe(JSON.stringify(token.payload)));
const data = encoded.join('.');
if (!(this.verifyRaw(data, token.signature, key, algorithm))) {
throw new JWTSignatureError();
}
if (!(token.payload instanceof String)) {
// `this.timeOffset`, `token.payload['nbf']` (not before) and `token.payload['exp']` (expires) are in seconds
if (token.payload['nbf']) {
const current = Math.floor((Date.now() / 1000));
if (token.payload['nbf'] > (current + this.timeOffset)) {
throw new JWTError('JWT is not active yet');
}
}
if (token.payload['exp']) {
const current = Math.floor((Date.now() / 1000));
if (current + this.timeOffset > token.payload['exp']) {
throw new JWTExpiredError(token.payload['exp']);
}
}
}
return token;
}
}
/**
* @module nomatic-jwt
*/
export default new JWT();