UNPKG

idtoken-verifier

Version:

A lightweight library to decode and verify RS JWT meant for the browser.

474 lines (424 loc) 13.5 kB
import p from 'es6-promise'; p.polyfill(); import sha256 from 'crypto-js/sha256'; import cryptoBase64 from 'crypto-js/enc-base64'; import cryptoHex from 'crypto-js/enc-hex'; import RSAVerifier from './helpers/rsa-verifier'; import * as base64 from './helpers/base64'; import * as jwks from './helpers/jwks'; import * as error from './helpers/error'; import DummyCache from './helpers/dummy-cache'; var supportedAlg = 'RS256'; var isNumber = n => typeof n === 'number'; var defaultClock = () => new Date(); var DEFAULT_LEEWAY = 60; /** * Creates a new id_token verifier * @constructor * @param {Object} parameters * @param {string} parameters.issuer name of the issuer of the token * that should match the `iss` claim in the id_token * @param {string} parameters.audience identifies the recipients that the JWT is intended for * and should match the `aud` claim * @param {Object} [parameters.jwksCache] cache for JSON Web Token Keys. By default it has no cache * @param {string} [parameters.jwksURI] A valid, direct URI to fetch the JSON Web Key Set (JWKS). * @param {string} [parameters.expectedAlg='RS256'] algorithm in which the id_token was signed * and will be used to validate * @param {number} [parameters.leeway=60] number of seconds that the clock can be out of sync * @param {number} [parameters.maxAge] max age * while validating expiration of the id_token */ function IdTokenVerifier(parameters) { var options = parameters || {}; this.jwksCache = options.jwksCache || new DummyCache(); this.expectedAlg = options.expectedAlg || 'RS256'; this.issuer = options.issuer; this.audience = options.audience; this.leeway = options.leeway === 0 ? 0 : options.leeway || DEFAULT_LEEWAY; this.jwksURI = options.jwksURI; this.maxAge = options.maxAge; this.__clock = typeof options.__clock === 'function' ? options.__clock : defaultClock; if (this.leeway < 0 || this.leeway > 300) { throw new error.ConfigurationError( 'The leeway should be positive and lower than five minutes.' ); } if (supportedAlg !== this.expectedAlg) { throw new error.ConfigurationError( 'Signature algorithm of "' + this.expectedAlg + '" is not supported. Expected the ID token to be signed with "' + supportedAlg + '".' ); } } /** * @callback verifyCallback * @param {?Error} err error returned if the verify cannot be performed * @param {?object} payload payload returned if the token is valid */ /** * Verifies an id_token * * It will validate: * - signature according to the algorithm configured in the verifier. * - if nonce is present and matches the one provided * - if `iss` and `aud` claims matches the configured issuer and audience * - if token is not expired and valid (if the `nbf` claim is in the past) * * @method verify * @param {string} token id_token to verify * @param {string} [requestedNonce] nonce value that should match the one in the id_token claims * @param {verifyCallback} cb callback used to notify the results of the validation */ IdTokenVerifier.prototype.verify = function(token, requestedNonce, cb) { if (!cb && requestedNonce && typeof requestedNonce == 'function') { cb = requestedNonce; requestedNonce = undefined; } if (!token) { return cb( new error.TokenValidationError('ID token is required but missing'), null ); } var jwt = this.decode(token); if (jwt instanceof Error) { return cb( new error.TokenValidationError('ID token could not be decoded'), null ); } /* eslint-disable vars-on-top */ var headerAndPayload = jwt.encoded.header + '.' + jwt.encoded.payload; var signature = base64.decodeToHEX(jwt.encoded.signature); var alg = jwt.header.alg; var kid = jwt.header.kid; var aud = jwt.payload.aud; var sub = jwt.payload.sub; var iss = jwt.payload.iss; var exp = jwt.payload.exp; var nbf = jwt.payload.nbf; var iat = jwt.payload.iat; var azp = jwt.payload.azp; var auth_time = jwt.payload.auth_time; var nonce = jwt.payload.nonce; var now = this.__clock(); /* eslint-enable vars-on-top */ var _this = this; if (_this.expectedAlg !== alg) { return cb( new error.TokenValidationError( 'Signature algorithm of "' + alg + '" is not supported. Expected the ID token to be signed with "' + supportedAlg + '".' ), null ); } this.getRsaVerifier(iss, kid, function(err, rsaVerifier) { if (err) { return cb(err, null); } if (!rsaVerifier.verify(headerAndPayload, signature)) { return cb( new error.TokenValidationError('Invalid ID token signature.'), null ); } if (!iss || typeof iss !== 'string') { return cb( new error.TokenValidationError( 'Issuer (iss) claim must be a string present in the ID token' ), null ); } if (_this.issuer !== iss) { return cb( new error.TokenValidationError( 'Issuer (iss) claim mismatch in the ID token, expected "' + _this.issuer + '", found "' + iss + '"' ), null ); } if (!sub || typeof sub !== 'string') { return cb( new error.TokenValidationError( 'Subject (sub) claim must be a string present in the ID token' ), null ); } if (!aud || (typeof aud !== 'string' && !Array.isArray(aud))) { return cb( new error.TokenValidationError( 'Audience (aud) claim must be a string or array of strings present in the ID token' ), null ); } if (Array.isArray(aud) && !aud.includes(_this.audience)) { return cb( new error.TokenValidationError( 'Audience (aud) claim mismatch in the ID token; expected "' + _this.audience + '" but was not one of "' + aud.join(', ') + '"' ), null ); } else if (typeof aud === 'string' && _this.audience !== aud) { return cb( new error.TokenValidationError( 'Audience (aud) claim mismatch in the ID token; expected "' + _this.audience + '" but found "' + aud + '"' ), null ); } if (requestedNonce) { if (!nonce || typeof nonce !== 'string') { return cb( new error.TokenValidationError( 'Nonce (nonce) claim must be a string present in the ID token' ), null ); } if (nonce !== requestedNonce) { return cb( new error.TokenValidationError( 'Nonce (nonce) claim value mismatch in the ID token; expected "' + requestedNonce + '", found "' + nonce + '"' ), null ); } } if (Array.isArray(aud) && aud.length > 1) { if (!azp || typeof azp !== 'string') { return cb( new error.TokenValidationError( 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' ), null ); } if (azp !== _this.audience) { return cb( new error.TokenValidationError( 'Authorized Party (azp) claim mismatch in the ID token; expected "' + _this.audience + '", found "' + azp + '"' ), null ); } } if (!exp || !isNumber(exp)) { return cb( new error.TokenValidationError( 'Expiration Time (exp) claim must be a number present in the ID token' ), null ); } if (!iat || !isNumber(iat)) { return cb( new error.TokenValidationError( 'Issued At (iat) claim must be a number present in the ID token' ), null ); } var expTime = exp + _this.leeway; var expTimeDate = new Date(0); expTimeDate.setUTCSeconds(expTime); if (now > expTimeDate) { return cb( new error.TokenValidationError( 'Expiration Time (exp) claim error in the ID token; current time "' + now + '" is after expiration time "' + expTimeDate + '"' ), null ); } if (nbf && isNumber(nbf)) { var nbfTime = nbf - _this.leeway; var nbfTimeDate = new Date(0); nbfTimeDate.setUTCSeconds(nbfTime); if (now < nbfTimeDate) { return cb( new error.TokenValidationError( 'Not Before Time (nbf) claim error in the ID token; current time "' + now + '" is before the not before time "' + nbfTimeDate + '"' ), null ); } } if (_this.maxAge) { if (!auth_time || !isNumber(auth_time)) { return cb( new error.TokenValidationError( 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' ), null ); } var authValidUntil = auth_time + _this.maxAge + _this.leeway; var authTimeDate = new Date(0); authTimeDate.setUTCSeconds(authValidUntil); if (now > authTimeDate) { return cb( new error.TokenValidationError( `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time "${now}" is after last auth time at "${authTimeDate}"` ), null ); } } return cb(null, jwt.payload); }); }; IdTokenVerifier.prototype.getRsaVerifier = function(iss, kid, cb) { var _this = this; var cachekey = iss + kid; Promise.resolve(this.jwksCache.has(cachekey)) .then(function(hasKey) { if (!hasKey) { return jwks.getJWKS({ jwksURI: _this.jwksURI, iss: iss, kid: kid }); } else { return _this.jwksCache.get(cachekey); } }) .then(function(keyInfo) { if (!keyInfo || !keyInfo.modulus || !keyInfo.exp) { throw new Error('Empty keyInfo in response'); } return Promise.resolve(_this.jwksCache.set(cachekey, keyInfo)).then( function() { cb && cb(null, new RSAVerifier(keyInfo.modulus, keyInfo.exp)); } ); }) .catch(function(err) { cb && cb(err); }); }; /** * @typedef DecodedToken * @type {Object} * @property {Object} header - content of the JWT header. * @property {Object} payload - token claims. * @property {Object} encoded - encoded parts of the token. */ /** * Decodes a well formed JWT without any verification * * @method decode * @param {string} token decodes the token * @return {DecodedToken} if token is valid according to `exp` and `nbf` */ IdTokenVerifier.prototype.decode = function(token) { var parts = token.split('.'); var header; var payload; if (parts.length !== 3) { return new error.TokenValidationError('Cannot decode a malformed JWT'); } try { header = JSON.parse(base64.decodeToString(parts[0])); payload = JSON.parse(base64.decodeToString(parts[1])); } catch (e) { return new error.TokenValidationError( 'Token header or payload is not valid JSON' ); } return { header: header, payload: payload, encoded: { header: parts[0], payload: parts[1], signature: parts[2] } }; }; /** * @callback validateAccessTokenCallback * @param {Error} [err] error returned if the validation cannot be performed * or the token is invalid. If there is no error, then the access_token is valid. */ /** * Validates an access_token based on {@link http://openid.net/specs/openid-connect-core-1_0.html#ImplicitTokenValidation}. * The id_token from where the alg and atHash parameters are taken, * should be decoded and verified before using thisfunction * * @method validateAccessToken * @param {string} access_token the access_token * @param {string} alg The algorithm defined in the header of the * previously verified id_token under the "alg" claim. * @param {string} atHash The "at_hash" value included in the payload * of the previously verified id_token. * @param {validateAccessTokenCallback} cb callback used to notify the results of the validation. */ IdTokenVerifier.prototype.validateAccessToken = function( accessToken, alg, atHash, cb ) { if (this.expectedAlg !== alg) { return cb( new error.TokenValidationError( 'Signature algorithm of "' + alg + '" is not supported. Expected "' + this.expectedAlg + '"' ) ); } var sha256AccessToken = sha256(accessToken); var hashToHex = cryptoHex.stringify(sha256AccessToken); var hashToHexFirstHalf = hashToHex.substring(0, hashToHex.length / 2); var hashFirstHalfWordArray = cryptoHex.parse(hashToHexFirstHalf); var hashFirstHalfBase64 = cryptoBase64.stringify(hashFirstHalfWordArray); var hashFirstHalfBase64SafeUrl = base64.base64ToBase64Url( hashFirstHalfBase64 ); if (hashFirstHalfBase64SafeUrl !== atHash) { return cb(new error.TokenValidationError('Invalid access_token')); } return cb(null); }; export default IdTokenVerifier;