283 lines
7.9 KiB
JavaScript
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;
|