const { inspect } = require('util'); const url = require('url'); const { RPError } = require('./errors'); const getClient = require('./client'); const registry = require('./issuer_registry'); const processResponse = require('./helpers/process_response'); const webfingerNormalize = require('./helpers/webfinger_normalize'); const request = require('./helpers/request'); const clone = require('./helpers/deep_clone'); const { keystore } = require('./helpers/issuer'); const AAD_MULTITENANT_DISCOVERY = [ 'https://login.microsoftonline.com/common/.well-known/openid-configuration', 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration', 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration', 'https://login.microsoftonline.com/consumers/v2.0/.well-known/openid-configuration', ]; const AAD_MULTITENANT = Symbol(); const ISSUER_DEFAULTS = { claim_types_supported: ['normal'], claims_parameter_supported: false, grant_types_supported: ['authorization_code', 'implicit'], request_parameter_supported: false, request_uri_parameter_supported: true, require_request_uri_registration: false, response_modes_supported: ['query', 'fragment'], token_endpoint_auth_methods_supported: ['client_secret_basic'], }; class Issuer { #metadata; constructor(meta = {}) { const aadIssValidation = meta[AAD_MULTITENANT]; delete meta[AAD_MULTITENANT]; ['introspection', 'revocation'].forEach((endpoint) => { // if intro/revocation endpoint auth specific meta is missing use the token ones if they // are defined if ( meta[`${endpoint}_endpoint`] && meta[`${endpoint}_endpoint_auth_methods_supported`] === undefined && meta[`${endpoint}_endpoint_auth_signing_alg_values_supported`] === undefined ) { if (meta.token_endpoint_auth_methods_supported) { meta[`${endpoint}_endpoint_auth_methods_supported`] = meta.token_endpoint_auth_methods_supported; } if (meta.token_endpoint_auth_signing_alg_values_supported) { meta[`${endpoint}_endpoint_auth_signing_alg_values_supported`] = meta.token_endpoint_auth_signing_alg_values_supported; } } }); this.#metadata = new Map(); Object.entries(meta).forEach(([key, value]) => { this.#metadata.set(key, value); if (!this[key]) { Object.defineProperty(this, key, { get() { return this.#metadata.get(key); }, enumerable: true, }); } }); registry.set(this.issuer, this); const Client = getClient(this, aadIssValidation); Object.defineProperties(this, { Client: { value: Client, enumerable: true }, FAPI1Client: { value: class FAPI1Client extends Client {}, enumerable: true }, FAPI2Client: { value: class FAPI2Client extends Client {}, enumerable: true }, }); } get metadata() { return clone(Object.fromEntries(this.#metadata.entries())); } static async webfinger(input) { const resource = webfingerNormalize(input); const { host } = url.parse(resource); const webfingerUrl = `https://${host}/.well-known/webfinger`; const response = await request.call(this, { method: 'GET', url: webfingerUrl, responseType: 'json', searchParams: { resource, rel: 'http://openid.net/specs/connect/1.0/issuer' }, headers: { Accept: 'application/json', }, }); const body = processResponse(response); const location = Array.isArray(body.links) && body.links.find( (link) => typeof link === 'object' && link.rel === 'http://openid.net/specs/connect/1.0/issuer' && link.href, ); if (!location) { throw new RPError({ message: 'no issuer found in webfinger response', body, }); } if (typeof location.href !== 'string' || !location.href.startsWith('https://')) { throw new RPError({ printf: ['invalid issuer location %s', location.href], body, }); } const expectedIssuer = location.href; if (registry.has(expectedIssuer)) { return registry.get(expectedIssuer); } const issuer = await this.discover(expectedIssuer); if (issuer.issuer !== expectedIssuer) { registry.del(issuer.issuer); throw new RPError( 'discovered issuer mismatch, expected %s, got: %s', expectedIssuer, issuer.issuer, ); } return issuer; } static async discover(uri) { const wellKnownUri = resolveWellKnownUri(uri); const response = await request.call(this, { method: 'GET', responseType: 'json', url: wellKnownUri, headers: { Accept: 'application/json', }, }); const body = processResponse(response); return new Issuer({ ...ISSUER_DEFAULTS, ...body, [AAD_MULTITENANT]: !!AAD_MULTITENANT_DISCOVERY.find((discoveryURL) => wellKnownUri.startsWith(discoveryURL), ), }); } async reloadJwksUri() { await keystore.call(this, true); } /* istanbul ignore next */ [inspect.custom]() { return `${this.constructor.name} ${inspect(this.metadata, { depth: Infinity, colors: process.stdout.isTTY, compact: false, sorted: true, })}`; } } function resolveWellKnownUri(uri) { const parsed = url.parse(uri); if (parsed.pathname.includes('/.well-known/')) { return uri; } else { let pathname; if (parsed.pathname.endsWith('/')) { pathname = `${parsed.pathname}.well-known/openid-configuration`; } else { pathname = `${parsed.pathname}/.well-known/openid-configuration`; } return url.format({ ...parsed, pathname }); } } module.exports = Issuer;