UNPKG

@smontero/fastify-az-jwt-verify

Version:

Azure JWT token verification plugin for Fastify

311 lines (267 loc) 10.9 kB
/* 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. */ 'use strict' 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 }