@smontero/fastify-az-jwt-verify
Version:
Azure JWT token verification plugin for Fastify
420 lines (363 loc) • 19.7 kB
JavaScript
/* 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.
*/
/* 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