index.js

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;