UNPKG

@smontero/fastify-az-jwt-verify

Version:

Azure JWT token verification plugin for Fastify

420 lines (363 loc) 19.7 kB
/* eslint-disable camelcase */ /** * Copyright (c) Microsoft Corporation * All Rights Reserved * MIT License * * Permission is hereby granted, free of charge, to any person obtaining a copy of this * software and associated documentation files (the 'Software'), to deal in the Software * without restriction, including without limitation the rights to use, copy, modify, * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 'use strict' /* eslint no-underscore-dangle: 0 */ const NodeCache = require('node-cache') const { Unauthorized } = require('http-errors') const jws = require('jws') const aadutils = require('./aadutils') const CONSTANTS = require('./constants') const jwt = require('./jsonWebToken') const Metadata = require('./Metadata') const UrlValidator = require('valid-url') const memoryCache = new NodeCache({ maxKeys: 3600, stdTTL: 1800 /* seconds */ }) /** * Applications must supply a `verify` callback, for which the function * signature is: * * function(token, done) { ... } * or * function(req, token, done) { ... } * * The latter enables you to use the request object. In order to use this * signature, the passReqToCallback value in options (see the Options instructions * below) must be set true, so the strategy knows you want to pass the request * to the `verify` callback function. * * `token` is the verified and decoded bearer token provided as a credential. * The verify callback is responsible for finding the user who posesses the * token, and invoking `done` with the following arguments: * * done(err, user, info); * * If the token is not valid, `user` should be set to `false` to indicate an * authentication failure. Additional token `info` can optionally be passed as * a third argument, which will be set by Passport at `req.authInfo`, where it * can be used by later middleware for access control. This is typically used * to pass any scope associated with the token. * * * Options: * * - `identityMetadata` (1) Required * (2) must be a https url string * (3) Description: * the metadata endpoint provided by the Microsoft Identity Portal that provides * the keys and other important info at runtime. Examples: * <1> v1 tenant-specific endpoint * - https://login.microsoftonline.com/your_tenant_name.onmicrosoft.com/.well-known/openid-configuration * - https://login.microsoftonline.com/your_tenant_guid/.well-known/openid-configuration * <2> v1 common endpoint * - https://login.microsoftonline.com/common/.well-known/openid-configuration * <3> v2 tenant-specific endpoint * - https://login.microsoftonline.com/your_tenant_name.onmicrosoft.com/v2.0/.well-known/openid-configuration * - https://login.microsoftonline.com/your_tenant_guid/v2.0/.well-known/openid-configuration * <4> v2 common endpoint * - https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration * * Note: you cannot use common endpoint for B2C * * - `clientId` (1) Required * (2) must be a string * (3) Description: * The Client ID of your app in AAD * * - `validateIssuer` (1) Required to set to false if you don't want to validate issuer, default value is true * (2) Description: * For common endpoint, you should either set `validateIssuer` to false, or provide the `issuer`, or provide `tenantIdOrName` * in passport.authenticate, since otherwise we cannot grab the `issuer` value from metadata. * For non-common endpoint, we use the `issuer` from metadata, and `validateIssuer` should be always true * * - `issuer` (1) Required if set `validateIssuer` to true, but there is no way to get the issuer. For example, when you are using * common endpoint, but you don't provide `tenantIdOrName` in passport.authenticate. * (2) must be a string or an array of strings * (3) Description: * For common endpoint, we use the `issuer` provided. * For non-common endpoint, if the `issuer` is not provided, we use the issuer provided by metadata * * - `isB2C` (1) Required to set to true for using B2C, default value is false * * - `policyName` (1) Required for using B2C * (2) Description: * policy name. Should be a string starting with 'B2C_1_' (case insensitive) * * * - `allowMultiAudiencesInToken` * (1) Required if you allow access_token whose `aud` claim contains multiple values * (2) Description: * The default value is false * * - `scope` (1) Optional * (2) Array of accepted scopes. * (3) Description: * If `scope` is provided, we will validate if access token contains any of the scopes listed in `scope`. * * * - `loggingNoPII` (1) Optional, default value is true * (2) Description: * If this is set to true, no personal information such as tokens and claims will be logged. * * - `audience` (1) Optional * (2) must be a string or an array of strings * (3) Description: * We invalidate the `aud` claim in access_token against `audience`. The default value is `clientId` * * - `clockSkew` (1) Optional * (2) must be a positive integer * (3) Description: * the clock skew (in seconds) allowed in token validation, default value is CLOCK_SKEW * Examples: * * passport.use(new BearerStrategy( * options, * function(token, done) { * User.findById(token.sub, function (err, user) { * if (err) { return done(err); } * if (!user) { return done(null, false); } * return done(null, user, token); * }); * } * )); * * The name of this strategy is 'oauth-bearer', so use this name as the first * parameter of the authenticate function. Moreover, we don't need session * support for request containing bearer tokens, so the session option can be * set to false. * * app.get('/protected_resource', * passport.authenticate('oauth-bearer', {session: false}), * function(req, res) { * ... * }); * * * For further details on HTTP Bearer authentication, refer to [The OAuth 2.0 Authorization Protocol: Bearer Tokens] * (http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer) * For further details on JSON Web Token, refert to [JSON Web Token](http://tools.ietf.org/html/draft-ietf-oauth-json-web-token) * * @param {object} options - The Options. * @param {Function} verify - The verify callback. * @constructor */ class BearerStrategy { constructor(log, options) { if (!options) { throw new Error('In BearerStrategy constructor: options is required') } this._options = options // --------------------------------------------------------------------------- // Set up the default values // --------------------------------------------------------------------------- // clock skew. Must be a postive integer if (options.clockSkew && (typeof options.clockSkew !== 'number' || options.clockSkew <= 0 || options.clockSkew % 1 !== 0)) { throw new Error('clockSkew must be a positive integer') } if (!options.clockSkew) { options.clockSkew = CONSTANTS.CLOCK_SKEW } // default value of validateIssuer is true if (options.validateIssuer !== false) { options.validateIssuer = true } // default value of allowMultiAudiencesInToken is false if (options.allowMultiAudiencesInToken !== true) { options.allowMultiAudiencesInToken = false } // if options.audience is a string or an array of string, then we use it; // otherwise we use the clientId if (options.audience && typeof options.audience === 'string') { options.audience = [options.audience] } else if (!options.audience || !Array.isArray(options.audience) || options.length === 0) { options.audience = [options.clientId, 'spn:' + options.clientId] } // default value of isB2C is false if (options.isB2C !== true) { options.isB2C = false } // turn issuer into an array if (options.issuer === '') { options.issuer = null } if (options.issuer && Array.isArray(options.issuer) && options.issuer.length === 0) { options.issuer = null } if (options.issuer && !Array.isArray(options.issuer)) { options.issuer = [options.issuer] } // --------------------------------------------------------------------------- // validate the things in options // --------------------------------------------------------------------------- // clientId should not be empty if (!options.clientId || options.clientId === '') { throw new Error('In BearerStrategy constructor: clientId cannot be empty') } // identityMetadata must be https url if (!options.identityMetadata || !UrlValidator.isHttpsUri(options.identityMetadata)) { throw new Error('In BearerStrategy constructor: identityMetadata must be provided and must be a https url') } // if scope is provided, it must be an array if (options.scope && (!Array.isArray(options.scope) || options.scope.length === 0)) { throw new Error('In BearerStrategy constructor: scope must be a non-empty array') } // --------------------------------------------------------------------------- // treatment of common endpoint and issuer // --------------------------------------------------------------------------- // check if we are using the common endpoint options._isCommonEndpoint = (options.identityMetadata.indexOf('/common/') !== -1) // give a warning if user is not validating issuer if (!options.validateIssuer) { log.warn('Production environments should always validate the issuer.') } // --------------------------------------------------------------------------- // B2C. // (1) policy must be provided and must have the valid prefix // (2) common endpoint is not supported // --------------------------------------------------------------------------- // for B2C, if (options.isB2C) { if (!options.policyName || !CONSTANTS.POLICY_REGEX.test(options.policyName)) { throw new Error('In BearerStrategy constructor: invalid policy for B2C') } } if (options.loggingNoPII !== false) { options.loggingNoPII = true } if (options.loggingNoPII) { log.info('In BearerStrategy constructor: strategy created') } else { log.info(`In BearerStrategy constructor: created strategy with options ${JSON.stringify(options)}`) } } jwtVerify(log, token, metadata, optionsToValidate) { const { loggingNoPII } = this._options const decoded = jws.decode(token) let PEMkey = null if (decoded == null) { throw new Unauthorized('In Strategy.prototype.jwtVerify: Invalid JWT token.') } if (loggingNoPII) { log.info('In Strategy.prototype.jwtVerify: token is decoded') } else { log.info('In Strategy.prototype.jwtVerify: token decoded: ', decoded) } // When we generate the PEMkey, there are two different types of token signatures // we have to validate here. One provides x5t and the other a kid. We need to call // the right one. try { if (decoded.header.x5t) { PEMkey = metadata.generateOidcPEM(log, decoded.header.x5t) } else if (decoded.header.kid) { PEMkey = metadata.generateOidcPEM(log, decoded.header.kid) } else { throw new Unauthorized('In Strategy.prototype.jwtVerify: We did not receive a token we know how to validate') } } catch (error) { throw new Unauthorized('In Strategy.prototype.jwtVerify: We did not receive a token we know how to validate') } if (loggingNoPII) { log.info('PEMkey generated') } else { log.info('PEMkey generated: ' + PEMkey) } const verifiedToken = jwt.verify(token, PEMkey, optionsToValidate) // scope validation if (optionsToValidate.scope) { if (!verifiedToken.scp) { throw new Unauthorized('In Strategy.prototype.jwtVerify: scope is not found in token') } // split scope by blanks and remove empty elements in the array var scopesInToken = verifiedToken.scp.split(/[ ]+/).filter(Boolean) var hasValidScopeInToken = false for (var i = 0; i < scopesInToken.length; i++) { if (optionsToValidate.scope.indexOf(scopesInToken[i]) !== -1) { hasValidScopeInToken = true break } } if (!hasValidScopeInToken) { if (loggingNoPII) { log.info('In Strategy.prototype.jwtVerify: none of the scopes in token is accepted') } else { log.info(`In Strategy.prototype.jwtVerify: none of the scopes '${verifiedToken.scp}' in token is accepted`) } throw new Unauthorized('Invalid scopes') } } if (loggingNoPII) { log.info('In Strategy.prototype.jwtVerify: token is verified') } else { log.info('In Strategy.prototype.jwtVerify: VerifiedToken: ', verifiedToken) } return verifiedToken } /* * We let the metadata loading happen in `authenticate` function, and use waterfall * to make sure the authentication code runs after the metadata loading is finished. */ async authenticate(log, req, options) { const self = this const optionsToValidate = {} const tenantIdOrName = options && options.tenantIdOrName const { loggingNoPII, validateIssuer, issuer, audience, allowMultiAudiencesInToken, ignoreExpiration, clockSkew, scope } = this._options let token if (req.headers && req.headers.authorization) { var auth_components = req.headers.authorization.split(' ') if (auth_components.length === 2 && auth_components[0].toLowerCase() === 'bearer') { token = auth_components[1] if (token !== '') { if (self._options.loggingNoPII) { log.info('In Strategy.prototype.authenticate: access_token is received from request header') } else { log.info(`In Strategy.prototype.authenticate: received access_token from request header: ${token}`) } } } } if (!token) { throw new Unauthorized('Access token not found') } const metadataURL = this._getMetadataUrl(log, tenantIdOrName) const metadata = await this.loadMetadata(log, metadataURL) if (loggingNoPII) { log.info('In Strategy.prototype.authenticate: received metadata') } else { log.info(`In Strategy.prototype.authenticate: received metadata: ${JSON.stringify(metadata)}`) } // set up issuer if (validateIssuer && !issuer) { optionsToValidate.issuer = metadata.oidc.issuer } else { optionsToValidate.issuer = self._options.issuer } // set up algorithm optionsToValidate.algorithms = metadata.oidc.algorithms // set up audience, validateIssuer, allowMultiAudiencesInToken optionsToValidate.audience = audience optionsToValidate.validateIssuer = validateIssuer optionsToValidate.allowMultiAudiencesInToken = allowMultiAudiencesInToken optionsToValidate.ignoreExpiration = ignoreExpiration // clock skew optionsToValidate.clockSkew = clockSkew // set up scope if (scope) { optionsToValidate.scope = scope } // Beaer token is considered as an access_token. optionsToValidate.isAccessToken = true if (loggingNoPII) { log.info('In Strategy.prototype.authenticate: we will validate the options') } else { log.info(`In Strategy.prototype.authenticate: we will validate the following options: ${JSON.stringify(optionsToValidate, null, 4)}`) } return self.jwtVerify(log, token, metadata, optionsToValidate) } _getMetadataUrl(log, tenantIdOrName) { const { identityMetadata, _isCommonEndpoint, loggingNoPII, validateIssuer, issuer, isB2C, policyName } = this._options let metadataURL = aadutils.concatUrl(identityMetadata, [ `${aadutils.getLibraryProductParameterName()}=${aadutils.getLibraryProduct()}`, `${aadutils.getLibraryVersionParameterName()}=${aadutils.getLibraryVersion()}` ] ) // if we are not using the common endpoint, but we have tenantIdOrName, just ignore it if (!_isCommonEndpoint && tenantIdOrName) { if (loggingNoPII) { log.info('identityMetadata is tenant-specific, so we ignore the provided tenantIdOrName') } else { log.info(`identityMetadata is tenant-specific, so we ignore the tenantIdOrName '${tenantIdOrName}'`) } tenantIdOrName = null } // if we are using common endpoint and we are given the tenantIdOrName, let's replace it if (_isCommonEndpoint && tenantIdOrName) { metadataURL = metadataURL.replace('/common/', `/${tenantIdOrName}/`) if (loggingNoPII) { log.info('We are replacing \'common\' with the provided tenantIdOrName') } else { log.info(`we are replacing 'common' with the tenantIdOrName ${tenantIdOrName}`) } } // if we are using the common endpoint and we want to validate issuer, then user has to // provide issuer in config, or provide tenant id or name using tenantIdOrName option in // passport.authenticate. Otherwise we won't know the issuer. if (_isCommonEndpoint && validateIssuer && (!issuer && !tenantIdOrName)) { throw new Error('In passport.authenticate: issuer or tenantIdOrName must be provided in order to validate issuer on common endpoint') } // for B2C, if we are using common endpoint, we must have tenantIdOrName provided if (isB2C && _isCommonEndpoint && !tenantIdOrName) { throw new Error('In passport.authenticate: we are using common endpoint for B2C but tenantIdOrName is not provided') } if (isB2C) { metadataURL = aadutils.concatUrl(metadataURL, `p=${policyName}`) } if (!loggingNoPII) { log.info(`In Strategy.prototype.authenticate: ${JSON.stringify(metadataURL)}`) } return metadataURL } async loadMetadata(log, url) { // fetch metadata let metadata = memoryCache.get(url) if (!metadata) { metadata = await Metadata.fetch({ log, url, authtype: 'oidc', options: this._options }) log.info('Storing metadata in cache...') memoryCache.set(url, metadata) } return metadata } } module.exports = BearerStrategy