2021-12-07 13:18:08 -05:00

283 lines
7.9 KiB
JavaScript

/* eslint-disable max-classes-per-file */
const { inspect } = require('util');
const url = require('url');
const AggregateError = require('aggregate-error');
const jose = require('jose');
const LRU = require('lru-cache');
const objectHash = require('object-hash');
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 instance = require('./helpers/weak_cache');
const request = require('./helpers/request');
const { assertIssuerConfiguration } = require('./helpers/assert');
const {
ISSUER_DEFAULTS, OIDC_DISCOVERY, OAUTH2_DISCOVERY, WEBFINGER, REL, AAD_MULTITENANT_DISCOVERY,
} = require('./helpers/consts');
const AAD_MULTITENANT = Symbol('AAD_MULTITENANT');
class Issuer {
/**
* @name constructor
* @api public
*/
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;
}
}
});
Object.entries(meta).forEach(([key, value]) => {
instance(this).get('metadata').set(key, value);
if (!this[key]) {
Object.defineProperty(this, key, {
get() { return instance(this).get('metadata').get(key); },
enumerable: true,
});
}
});
instance(this).set('cache', new LRU({ max: 100 }));
registry.set(this.issuer, this);
const Client = getClient(this, aadIssValidation);
Object.defineProperties(this, {
Client: { value: Client },
FAPIClient: { value: class FAPIClient extends Client {} },
});
}
/**
* @name keystore
* @api public
*/
async keystore(reload = false) {
assertIssuerConfiguration(this, 'jwks_uri');
const keystore = instance(this).get('keystore');
const cache = instance(this).get('cache');
if (reload || !keystore) {
cache.reset();
const response = await request.call(this, {
method: 'GET',
responseType: 'json',
url: this.jwks_uri,
});
const jwks = processResponse(response);
const joseKeyStore = jose.JWKS.asKeyStore(jwks, { ignoreErrors: true });
cache.set('throttle', true, 60 * 1000);
instance(this).set('keystore', joseKeyStore);
return joseKeyStore;
}
return keystore;
}
/**
* @name queryKeyStore
* @api private
*/
async queryKeyStore({
kid, kty, alg, use, key_ops: ops,
}, { allowMulti = false } = {}) {
const cache = instance(this).get('cache');
const def = {
kid, kty, alg, use, key_ops: ops,
};
const defHash = objectHash(def, {
algorithm: 'sha256',
ignoreUnknown: true,
unorderedArrays: true,
unorderedSets: true,
});
// refresh keystore on every unknown key but also only upto once every minute
const freshJwksUri = cache.get(defHash) || cache.get('throttle');
const keystore = await this.keystore(!freshJwksUri);
const keys = keystore.all(def);
if (keys.length === 0) {
throw new RPError({
printf: ["no valid key found in issuer's jwks_uri for key parameters %j", def],
jwks: keystore,
});
}
if (!allowMulti && keys.length > 1 && !kid) {
throw new RPError({
printf: ["multiple matching keys found in issuer's jwks_uri for key parameters %j, kid must be provided in this case", def],
jwks: keystore,
});
}
cache.set(defHash, true);
return new jose.JWKS.KeyStore(keys);
}
/**
* @name metadata
* @api public
*/
get metadata() {
const copy = {};
instance(this).get('metadata').forEach((value, key) => {
copy[key] = value;
});
return copy;
}
/**
* @name webfinger
* @api public
*/
static async webfinger(input) {
const resource = webfingerNormalize(input);
const { host } = url.parse(resource);
const webfingerUrl = `https://${host}${WEBFINGER}`;
const response = await request.call(this, {
method: 'GET',
url: webfingerUrl,
responseType: 'json',
searchParams: { resource, rel: REL },
followRedirect: true,
});
const body = processResponse(response);
const location = Array.isArray(body.links) && body.links.find((link) => typeof link === 'object' && link.rel === REL && 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.delete(issuer.issuer);
throw new RPError('discovered issuer mismatch, expected %s, got: %s', expectedIssuer, issuer.issuer);
}
return issuer;
}
/**
* @name discover
* @api public
*/
static async discover(uri) {
const parsed = url.parse(uri);
if (parsed.pathname.includes('/.well-known/')) {
const response = await request.call(this, {
method: 'GET',
responseType: 'json',
url: uri,
});
const body = processResponse(response);
return new Issuer({
...ISSUER_DEFAULTS,
...body,
[AAD_MULTITENANT]: !!AAD_MULTITENANT_DISCOVERY.find(
(discoveryURL) => uri.startsWith(discoveryURL),
),
});
}
const pathnames = [];
if (parsed.pathname.endsWith('/')) {
pathnames.push(`${parsed.pathname}${OIDC_DISCOVERY.substring(1)}`);
} else {
pathnames.push(`${parsed.pathname}${OIDC_DISCOVERY}`);
}
if (parsed.pathname === '/') {
pathnames.push(`${OAUTH2_DISCOVERY}`);
} else {
pathnames.push(`${OAUTH2_DISCOVERY}${parsed.pathname}`);
}
const errors = [];
// eslint-disable-next-line no-restricted-syntax
for (const pathname of pathnames) {
try {
const wellKnownUri = url.format({ ...parsed, pathname });
// eslint-disable-next-line no-await-in-loop
const response = await request.call(this, {
method: 'GET',
responseType: 'json',
url: wellKnownUri,
});
const body = processResponse(response);
return new Issuer({
...ISSUER_DEFAULTS,
...body,
[AAD_MULTITENANT]: !!AAD_MULTITENANT_DISCOVERY.find(
(discoveryURL) => wellKnownUri.startsWith(discoveryURL),
),
});
} catch (err) {
errors.push(err);
}
}
const err = new AggregateError(errors);
err.message = `Issuer.discover() failed.${err.message.split('\n')
.filter((line) => !line.startsWith(' at')).join('\n')}`;
throw err;
}
/* istanbul ignore next */
[inspect.custom]() {
return `${this.constructor.name} ${inspect(this.metadata, {
depth: Infinity,
colors: process.stdout.isTTY,
compact: false,
sorted: true,
})}`;
}
}
module.exports = Issuer;