@smontero/fastify-az-jwt-verify
Version:
Azure JWT token verification plugin for Fastify
311 lines (267 loc) • 10.9 kB
JavaScript
/* eslint-disable no-irregular-whitespace */
/**
* 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.
*/
const base64url = require('base64url')
const crypto = require('crypto')
const util = require('util')
exports.getLibraryProduct = () => { return 'passport-azure-ad' }
exports.getLibraryVersionParameterName = () => { return 'x-client-Ver' }
exports.getLibraryProductParameterName = () => { return 'x-client-SKU' }
exports.getLibraryVersion = () => {
return '4.0.0'
}
exports.getElement = (parentElement, elementName) => {
if (parentElement[`saml:${elementName}`]) {
return parentElement[`saml:${elementName}`]
} else if (parentElement[`samlp:${elementName}`]) {
return parentElement[`samlp:${elementName}`]
} else if (parentElement[`wsa:${elementName}`]) {
return parentElement[`wsa:${elementName}`]
}
return parentElement[elementName]
}
exports.getFirstElement = (parentElement, elementName) => {
const element = exports.getElement(parentElement, elementName)
return Array.isArray(element) ? element[0] : element
}
/**
* Reconstructs the original URL of the request.
*
* This function builds a URL that corresponds the original URL requested by the
* client, including the protocol (http or https) and host.
*
* If the request passed through any proxies that terminate SSL, the
* `X-Forwarded-Proto` header is used to detect if the request was encrypted to
* the proxy.
*
* @return {String}
* @api private
*/
exports.originalURL = (req) => {
const headers = req.headers
const protocol = (req.connection.encrypted || req.headers['x-forwarded-proto'] === 'https') ? 'https' : 'http'
const host = headers.host
const path = req.url || ''
return `${protocol}://${host}${path}`
}
/**
* Merge object b with object a.
*
* var a = { something: 'bar' }
* , b = { bar: 'baz' };
*
* utils.merge(a, b);
* // => { something: 'bar', bar: 'baz' }
*
* @param {Object} a
* @param {Object} b
* @return {Object}
* @api private
*/
exports.merge = (a, b) => {
// eslint-disable-next-line node/no-deprecated-api
return util._extend(a, b) // eslint-disable-line no-underscore-dangle
}
/**
* Return a unique identifier with the given `len`.
*
* utils.uid(10);
* // => "FDaS435D2z"
*
* CREDIT: Connect -- utils.uid
* https://github.com/senchalabs/connect/blob/2.7.2/lib/utils.js
*
* @param {Number} len
* @return {String}
* @api private
*/
exports.uid = (len) => {
var bytes = crypto.randomBytes(Math.ceil(len * 3 / 4))
return base64url.encode(bytes).slice(0, len)
}
function prepadSigned(hexStr) {
const msb = hexStr[0]
if (msb < '0' || msb > '7') {
return `00${hexStr}`
}
return hexStr
}
function toHex(number) {
const nstr = number.toString(16)
if (nstr.length % 2) {
return `0${nstr}`
}
return nstr
}
// encode ASN.1 DER length field
// if <=127, short form
// if >=128, long form
function encodeLengthHex(n) {
if (n <= 127) {
return toHex(n)
}
const nHex = toHex(n)
const lengthOfLengthByte = 128 + nHex.length / 2 // 0x80+numbytes
return toHex(lengthOfLengthByte) + nHex
}
// http://stackoverflow.com/questions/18835132/xml-to-pem-in-node-js
exports.rsaPublicKeyPem = (modulusB64, exponentB64) => {
const modulus = Buffer.from(modulusB64, 'base64')
const exponent = Buffer.from(exponentB64, 'base64')
const modulusHex = prepadSigned(modulus.toString('hex'))
const exponentHex = prepadSigned(exponent.toString('hex'))
const modlen = modulusHex.length / 2
const explen = exponentHex.length / 2
const encodedModlen = encodeLengthHex(modlen)
const encodedExplen = encodeLengthHex(explen)
const encodedPubkey = `30${encodeLengthHex(
modlen +
explen +
encodedModlen.length / 2 +
encodedExplen.length / 2 + 2
)}02${encodedModlen}${modulusHex}02${encodedExplen}${exponentHex}`
const derB64 = Buffer.from(encodedPubkey, 'hex').toString('base64')
const pem = `-----BEGIN RSA PUBLIC KEY-----\n${derB64.match(/.{1,64}/g).join('\n')}\n-----END RSA PUBLIC KEY-----\n`
return pem
}
// used for c_hash and at_hash validation
// case (1): content = access_token, hashProvided = at_hash
// case (2): content = code, hashProvided = c_hash
exports.checkHashValueRS256 = (content, hashProvided) => {
if (!content) { return false }
// step 1. hash the content
var digest = crypto.createHash('sha256').update(content, 'ascii').digest()
// step2. take the first half of the digest, and save it in a buffer
var buffer = Buffer.alloc(digest.length / 2)
for (var i = 0; i < buffer.length; i++) { buffer[i] = digest[i] }
// step 3. base64url encode the buffer to get the hash
var hashComputed = base64url(buffer)
return (hashProvided === hashComputed)
}
// This function is used for handling the tuples containing nonce/state/policy/timeStamp in session
// remove the additional tuples from array starting from the oldest ones
// remove expired tuples in array
exports.processArray = function(array, maxAmount, maxAge) {
// remove the additional tuples, start from the oldest ones
if (array.length > maxAmount) { array.splice(0, array.length - maxAmount) }
// count the number of those already expired
var count = 0
for (var i = 0; i < array.length; i++) {
var tuple = array[i]
if (tuple.timeStamp + maxAge * 1000 <= Date.now()) { count++ } else { break }
}
// remove the expired ones
if (count > 0) { array.splice(0, count) }
}
// This function is used to find the tuple matching the given state, remove the tuple
// from the array and return the tuple
// @array - array of {state: x, nonce: x, policy: x, timeStamp: x} tuples
// @state - the tuple which matches the given state
exports.findAndDeleteTupleByState = (array, state) => {
if (!array) { return null }
for (var i = 0; i < array.length; i++) {
var tuple = array[i]
if (tuple.state === state) {
// remove the tuple from the array
array.splice(i, 1)
return tuple
}
}
return null
}
// copy the fields from source to dest
exports.copyObjectFields = (source, dest, fields) => {
if (!source || !dest || !fields || !Array.isArray(fields)) { return }
for (var i = 0; i < fields.length; i++) { dest[fields[i]] = source[fields[i]] }
}
exports.getErrorMessage = (err) => {
if (typeof err === 'string') { return err }
if (err instanceof Error) { return err.message }
// if not string or Error, we try to stringify it
var str
try {
str = JSON.stringify(err)
} catch (ex) {
return err
}
return str
}
exports.concatUrl = (url, rest) => {
if (typeof rest === 'string' || rest instanceof String) {
rest = [rest]
}
if (!url) {
return `?${rest.join('&')}`
}
var hasParam = url.indexOf('?') !== -1
return rest ? url.concat(hasParam ? '&' : '?').concat(rest.join('&')) : url
}
// This is a list maintained by the AAD server team, will need to keep an eye on this
// as things change. Not ideal, but it is what it is
// in general, a change like this (adding a new cookie attribute)
// should be backward compatible as RFC 6265 specifies that browsers should
// ignore unknown cookie attributes. However, for the specific case of the
// SameSite attribute, some browsers incorrectly implemented the attribute or
// implemented an earlier draft which had contradictory behavior.
// For these browsers which attempted to support SameSite,
// but have bugs in their support, we want to omit the SameSite=None attribute.
// See Chromium official guidance here: https://www.chromium.org/updates/same-site/incompatible-clients
exports.sameSiteNotAllowed = (userAgent) => {
// Cover all iOS based browsers here. This includes:
// - Safari on iOS 12 for iPhone, iPod Touch, iPad
// - WkWebview on iOS 12 for iPhone, iPod Touch, iPad
// - Chrome on iOS 12 for iPhone, iPod Touch, iPad
// All of which are broken by SameSite=None, because they use the iOS networking stack
if (userAgent.includes('CPU iPhone OS 12') || userAgent.includes('iPad; CPU OS 12')) {
return true
}
// Cover Mac OS X based browsers that use the Mac OS networking stack. This includes:
// - Safari on Mac OS X
// - Internal browser on Mac OS X
// This does not include:
// - Chrome on Mac OS X
// - Chromium on Mac OS X
// Because they do not use the Mac OS networking stack.
if (userAgent.includes('Macintosh; Intel Mac OS X 10_14') && !userAgent.includes('Chrome/') && !userAgent.includes('Chromium')) {
return true
}
// Cover Chrome 50-69, because some versions are broken by SameSite=None, and none in this range require it.
// Note: this covers some pre-Chromium Edge versions, but pre-Chromim Edge does not require SameSite=None, so this is fine.
// Note: this regex applies to Windows, Mac OS X, and Linux, deliberately.
if (userAgent.includes('Chrome/5') || userAgent.includes('Chrome/6')) {
return true
}
// Unreal Engine runs Chromium 59, but does not advertise as Chrome until 4.23. Treat versions of Unreal
// that don't specify their Chrome version as lacking support for SameSite=None.
if (userAgent.includes('UnrealEngine') && !userAgent.includes('Chrome')) {
return true
}
// UCBrowser < 12.13.2 ignores Set-Cookie headers with SameSite=None.
// NB: this rule isn't complete - you need regex to make a complete rule.
// See: https://www.chromium.org/updates/same-site/incompatible-clients
if (userAgent.includes('UCBrowser/12') || userAgent.includes('UCBrowser/11')) {
return true
}
return false
}