/* eslint-disable max-classes-per-file */ const { inspect } = require('util'); const stdhttp = require('http'); const crypto = require('crypto'); const { strict: assert } = require('assert'); const querystring = require('querystring'); const url = require('url'); const { ParseError } = require('got'); const jose = require('jose'); const tokenHash = require('oidc-token-hash'); const base64url = require('./helpers/base64url'); const defaults = require('./helpers/defaults'); const { assertSigningAlgValuesSupport, assertIssuerConfiguration } = require('./helpers/assert'); const pick = require('./helpers/pick'); const isPlainObject = require('./helpers/is_plain_object'); const processResponse = require('./helpers/process_response'); const TokenSet = require('./token_set'); const { OPError, RPError } = require('./errors'); const now = require('./helpers/unix_timestamp'); const { random } = require('./helpers/generators'); const request = require('./helpers/request'); const { CALLBACK_PROPERTIES, CLIENT_DEFAULTS, JWT_CONTENT, CLOCK_TOLERANCE, } = require('./helpers/consts'); const issuerRegistry = require('./issuer_registry'); const instance = require('./helpers/weak_cache'); const { authenticatedPost, resolveResponseType, resolveRedirectUri } = require('./helpers/client'); const DeviceFlowHandle = require('./device_flow_handle'); function pickCb(input) { return pick(input, ...CALLBACK_PROPERTIES); } function authorizationHeaderValue(token, tokenType = 'Bearer') { return `${tokenType} ${token}`; } function cleanUpClaims(claims) { if (Object.keys(claims._claim_names).length === 0) { delete claims._claim_names; } if (Object.keys(claims._claim_sources).length === 0) { delete claims._claim_sources; } } function assignClaim(target, source, sourceName, throwOnMissing = true) { return ([claim, inSource]) => { if (inSource === sourceName) { if (throwOnMissing && source[claim] === undefined) { throw new RPError(`expected claim "${claim}" in "${sourceName}"`); } else if (source[claim] !== undefined) { target[claim] = source[claim]; } delete target._claim_names[claim]; } }; } function verifyPresence(payload, jwt, prop) { if (payload[prop] === undefined) { throw new RPError({ message: `missing required JWT property ${prop}`, jwt, }); } } function authorizationParams(params) { const authParams = { client_id: this.client_id, scope: 'openid', response_type: resolveResponseType.call(this), redirect_uri: resolveRedirectUri.call(this), ...params, }; Object.entries(authParams).forEach(([key, value]) => { if (value === null || value === undefined) { delete authParams[key]; } else if (key === 'claims' && typeof value === 'object') { authParams[key] = JSON.stringify(value); } else if (key === 'resource' && Array.isArray(value)) { authParams[key] = value; } else if (typeof value !== 'string') { authParams[key] = String(value); } }); return authParams; } async function claimJWT(label, jwt) { try { const { header, payload } = jose.JWT.decode(jwt, { complete: true }); const { iss } = payload; if (header.alg === 'none') { return payload; } let key; if (!iss || iss === this.issuer.issuer) { key = await this.issuer.queryKeyStore(header); } else if (issuerRegistry.has(iss)) { key = await issuerRegistry.get(iss).queryKeyStore(header); } else { const discovered = await this.issuer.constructor.discover(iss); key = await discovered.queryKeyStore(header); } return jose.JWT.verify(jwt, key); } catch (err) { if (err instanceof RPError || err instanceof OPError || err.name === 'AggregateError') { throw err; } else { throw new RPError({ printf: ['failed to validate the %s JWT (%s: %s)', label, err.name, err.message], jwt, }); } } } function getKeystore(jwks) { if (!isPlainObject(jwks) || !Array.isArray(jwks.keys) || jwks.keys.some((k) => !isPlainObject(k) || !('kty' in k))) { throw new TypeError('jwks must be a JSON Web Key Set formatted object'); } // eslint-disable-next-line no-restricted-syntax for (const jwk of jwks.keys) { if (jwk.kid === undefined) { jwk.kid = `DONOTUSE.${random()}`; } } const keystore = jose.JWKS.asKeyStore(jwks); if (keystore.all().some((key) => key.type !== 'private')) { throw new TypeError('jwks must only contain private keys'); } return keystore; } // if an OP doesnt support client_secret_basic but supports client_secret_post, use it instead // this is in place to take care of most common pitfalls when first using discovered Issuers without // the support for default values defined by Discovery 1.0 function checkBasicSupport(client, metadata, properties) { try { const supported = client.issuer.token_endpoint_auth_methods_supported; if (!supported.includes(properties.token_endpoint_auth_method)) { if (supported.includes('client_secret_post')) { properties.token_endpoint_auth_method = 'client_secret_post'; } } } catch (err) {} } function handleCommonMistakes(client, metadata, properties) { if (!metadata.token_endpoint_auth_method) { // if no explicit value was provided checkBasicSupport(client, metadata, properties); } // :fp: c'mon people... RTFM if (metadata.redirect_uri) { if (metadata.redirect_uris) { throw new TypeError('provide a redirect_uri or redirect_uris, not both'); } properties.redirect_uris = [metadata.redirect_uri]; delete properties.redirect_uri; } if (metadata.response_type) { if (metadata.response_types) { throw new TypeError('provide a response_type or response_types, not both'); } properties.response_types = [metadata.response_type]; delete properties.response_type; } } function getDefaultsForEndpoint(endpoint, issuer, properties) { if (!issuer[`${endpoint}_endpoint`]) return; const tokenEndpointAuthMethod = properties.token_endpoint_auth_method; const tokenEndpointAuthSigningAlg = properties.token_endpoint_auth_signing_alg; const eam = `${endpoint}_endpoint_auth_method`; const easa = `${endpoint}_endpoint_auth_signing_alg`; if (properties[eam] === undefined && properties[easa] === undefined) { if (tokenEndpointAuthMethod !== undefined) { properties[eam] = tokenEndpointAuthMethod; } if (tokenEndpointAuthSigningAlg !== undefined) { properties[easa] = tokenEndpointAuthSigningAlg; } } } class BaseClient {} module.exports = (issuer, aadIssValidation = false) => class Client extends BaseClient { /** * @name constructor * @api public */ constructor(metadata = {}, jwks, options) { super(); if (typeof metadata.client_id !== 'string' || !metadata.client_id) { throw new TypeError('client_id is required'); } const properties = { ...CLIENT_DEFAULTS, ...metadata }; handleCommonMistakes(this, metadata, properties); assertSigningAlgValuesSupport('token', this.issuer, properties); ['introspection', 'revocation'].forEach((endpoint) => { getDefaultsForEndpoint(endpoint, this.issuer, properties); assertSigningAlgValuesSupport(endpoint, this.issuer, properties); }); Object.entries(properties).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, }); } }); if (jwks !== undefined) { const keystore = getKeystore.call(this, jwks); instance(this).set('keystore', keystore); } if (options !== undefined) { instance(this).set('options', options); } this[CLOCK_TOLERANCE] = 0; } /** * @name authorizationUrl * @api public */ authorizationUrl(params = {}) { if (!isPlainObject(params)) { throw new TypeError('params must be a plain object'); } assertIssuerConfiguration(this.issuer, 'authorization_endpoint'); const target = url.parse(this.issuer.authorization_endpoint, true); target.search = null; target.query = { ...target.query, ...authorizationParams.call(this, params), }; return url.format(target); } /** * @name authorizationPost * @api public */ authorizationPost(params = {}) { if (!isPlainObject(params)) { throw new TypeError('params must be a plain object'); } const inputs = authorizationParams.call(this, params); const formInputs = Object.keys(inputs) .map((name) => ``).join('\n'); return `