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

1734 lines
48 KiB
JavaScript

/* 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) => `<input type="hidden" name="${name}" value="${inputs[name]}"/>`).join('\n');
return `<!DOCTYPE html>
<head>
<title>Requesting Authorization</title>
</head>
<body onload="javascript:document.forms[0].submit()">
<form method="post" action="${this.issuer.authorization_endpoint}">
${formInputs}
</form>
</body>
</html>`;
}
/**
* @name endSessionUrl
* @api public
*/
endSessionUrl(params = {}) {
assertIssuerConfiguration(this.issuer, 'end_session_endpoint');
const {
0: postLogout,
length,
} = this.post_logout_redirect_uris || [];
const {
post_logout_redirect_uri = length === 1 ? postLogout : undefined,
} = params;
let hint = params.id_token_hint;
if (hint instanceof TokenSet) {
if (!hint.id_token) {
throw new TypeError('id_token not present in TokenSet');
}
hint = hint.id_token;
}
const target = url.parse(this.issuer.end_session_endpoint, true);
target.search = null;
target.query = {
...params,
...target.query,
...{
post_logout_redirect_uri,
id_token_hint: hint,
},
};
Object.entries(target.query).forEach(([key, value]) => {
if (value === null || value === undefined) {
delete target.query[key];
}
});
return url.format(target);
}
/**
* @name callbackParams
* @api public
*/
callbackParams(input) { // eslint-disable-line class-methods-use-this
const isIncomingMessage = input instanceof stdhttp.IncomingMessage
|| (input && input.method && input.url);
const isString = typeof input === 'string';
if (!isString && !isIncomingMessage) {
throw new TypeError('#callbackParams only accepts string urls, http.IncomingMessage or a lookalike');
}
if (isIncomingMessage) {
switch (input.method) {
case 'GET':
return pickCb(url.parse(input.url, true).query);
case 'POST':
if (input.body === undefined) {
throw new TypeError('incoming message body missing, include a body parser prior to this method call');
}
switch (typeof input.body) {
case 'object':
case 'string':
if (Buffer.isBuffer(input.body)) {
return pickCb(querystring.parse(input.body.toString('utf-8')));
}
if (typeof input.body === 'string') {
return pickCb(querystring.parse(input.body));
}
return pickCb(input.body);
default:
throw new TypeError('invalid IncomingMessage body object');
}
default:
throw new TypeError('invalid IncomingMessage method');
}
} else {
return pickCb(url.parse(input, true).query);
}
}
/**
* @name callback
* @api public
*/
async callback(
redirectUri,
parameters,
checks = {},
{ exchangeBody, clientAssertionPayload, DPoP } = {},
) {
let params = pickCb(parameters);
if (checks.jarm && !('response' in parameters)) {
throw new RPError({
message: 'expected a JARM response',
checks,
params,
});
} else if ('response' in parameters) {
const decrypted = await this.decryptJARM(params.response);
params = await this.validateJARM(decrypted);
}
if (this.default_max_age && !checks.max_age) {
checks.max_age = this.default_max_age;
}
if (params.state && !checks.state) {
throw new TypeError('checks.state argument is missing');
}
if (!params.state && checks.state) {
throw new RPError({
message: 'state missing from the response',
checks,
params,
});
}
if (checks.state !== params.state) {
throw new RPError({
printf: ['state mismatch, expected %s, got: %s', checks.state, params.state],
checks,
params,
});
}
if (params.error) {
throw new OPError(params);
}
const RESPONSE_TYPE_REQUIRED_PARAMS = {
code: ['code'],
id_token: ['id_token'],
token: ['access_token', 'token_type'],
};
if (checks.response_type) {
for (const type of checks.response_type.split(' ')) { // eslint-disable-line no-restricted-syntax
if (type === 'none') {
if (params.code || params.id_token || params.access_token) {
throw new RPError({
message: 'unexpected params encountered for "none" response',
checks,
params,
});
}
} else {
for (const param of RESPONSE_TYPE_REQUIRED_PARAMS[type]) { // eslint-disable-line no-restricted-syntax, max-len
if (!params[param]) {
throw new RPError({
message: `${param} missing from response`,
checks,
params,
});
}
}
}
}
}
if (params.id_token) {
const tokenset = new TokenSet(params);
await this.decryptIdToken(tokenset);
await this.validateIdToken(tokenset, checks.nonce, 'authorization', checks.max_age, checks.state);
if (!params.code) {
return tokenset;
}
}
if (params.code) {
const tokenset = await this.grant({
...exchangeBody,
grant_type: 'authorization_code',
code: params.code,
redirect_uri: redirectUri,
code_verifier: checks.code_verifier,
}, { clientAssertionPayload, DPoP });
await this.decryptIdToken(tokenset);
await this.validateIdToken(tokenset, checks.nonce, 'token', checks.max_age);
if (params.session_state) {
tokenset.session_state = params.session_state;
}
return tokenset;
}
return new TokenSet(params);
}
/**
* @name oauthCallback
* @api public
*/
async oauthCallback(
redirectUri,
parameters,
checks = {},
{ exchangeBody, clientAssertionPayload, DPoP } = {},
) {
let params = pickCb(parameters);
if (checks.jarm && !('response' in parameters)) {
throw new RPError({
message: 'expected a JARM response',
checks,
params,
});
} else if ('response' in parameters) {
const decrypted = await this.decryptJARM(params.response);
params = await this.validateJARM(decrypted);
}
if (params.state && !checks.state) {
throw new TypeError('checks.state argument is missing');
}
if (!params.state && checks.state) {
throw new RPError({
message: 'state missing from the response',
checks,
params,
});
}
if (checks.state !== params.state) {
throw new RPError({
printf: ['state mismatch, expected %s, got: %s', checks.state, params.state],
checks,
params,
});
}
if (params.error) {
throw new OPError(params);
}
const RESPONSE_TYPE_REQUIRED_PARAMS = {
code: ['code'],
token: ['access_token', 'token_type'],
};
if (checks.response_type) {
for (const type of checks.response_type.split(' ')) { // eslint-disable-line no-restricted-syntax
if (type === 'none') {
if (params.code || params.id_token || params.access_token) {
throw new RPError({
message: 'unexpected params encountered for "none" response',
checks,
params,
});
}
}
if (RESPONSE_TYPE_REQUIRED_PARAMS[type]) {
for (const param of RESPONSE_TYPE_REQUIRED_PARAMS[type]) { // eslint-disable-line no-restricted-syntax, max-len
if (!params[param]) {
throw new RPError({
message: `${param} missing from response`,
checks,
params,
});
}
}
}
}
}
if (params.code) {
return this.grant({
...exchangeBody,
grant_type: 'authorization_code',
code: params.code,
redirect_uri: redirectUri,
code_verifier: checks.code_verifier,
}, { clientAssertionPayload, DPoP });
}
return new TokenSet(params);
}
/**
* @name decryptIdToken
* @api private
*/
async decryptIdToken(token) {
if (!this.id_token_encrypted_response_alg) {
return token;
}
let idToken = token;
if (idToken instanceof TokenSet) {
if (!idToken.id_token) {
throw new TypeError('id_token not present in TokenSet');
}
idToken = idToken.id_token;
}
const expectedAlg = this.id_token_encrypted_response_alg;
const expectedEnc = this.id_token_encrypted_response_enc;
const result = await this.decryptJWE(idToken, expectedAlg, expectedEnc);
if (token instanceof TokenSet) {
token.id_token = result;
return token;
}
return result;
}
async validateJWTUserinfo(body) {
const expectedAlg = this.userinfo_signed_response_alg;
return this.validateJWT(body, expectedAlg, []);
}
/**
* @name decryptJARM
* @api private
*/
async decryptJARM(response) {
if (!this.authorization_encrypted_response_alg) {
return response;
}
const expectedAlg = this.authorization_encrypted_response_alg;
const expectedEnc = this.authorization_encrypted_response_enc;
return this.decryptJWE(response, expectedAlg, expectedEnc);
}
/**
* @name decryptJWTUserinfo
* @api private
*/
async decryptJWTUserinfo(body) {
if (!this.userinfo_encrypted_response_alg) {
return body;
}
const expectedAlg = this.userinfo_encrypted_response_alg;
const expectedEnc = this.userinfo_encrypted_response_enc;
return this.decryptJWE(body, expectedAlg, expectedEnc);
}
/**
* @name decryptJWE
* @api private
*/
async decryptJWE(jwe, expectedAlg, expectedEnc = 'A128CBC-HS256') {
const header = JSON.parse(base64url.decode(jwe.split('.')[0]));
if (header.alg !== expectedAlg) {
throw new RPError({
printf: ['unexpected JWE alg received, expected %s, got: %s', expectedAlg, header.alg],
jwt: jwe,
});
}
if (header.enc !== expectedEnc) {
throw new RPError({
printf: ['unexpected JWE enc received, expected %s, got: %s', expectedEnc, header.enc],
jwt: jwe,
});
}
let keyOrStore;
if (expectedAlg.match(/^(?:RSA|ECDH)/)) {
keyOrStore = instance(this).get('keystore');
} else {
keyOrStore = await this.joseSecret(expectedAlg === 'dir' ? expectedEnc : expectedAlg);
}
const payload = jose.JWE.decrypt(jwe, keyOrStore);
return payload.toString('utf8');
}
/**
* @name validateIdToken
* @api private
*/
async validateIdToken(tokenSet, nonce, returnedBy, maxAge, state) {
let idToken = tokenSet;
const expectedAlg = this.id_token_signed_response_alg;
const isTokenSet = idToken instanceof TokenSet;
if (isTokenSet) {
if (!idToken.id_token) {
throw new TypeError('id_token not present in TokenSet');
}
idToken = idToken.id_token;
}
idToken = String(idToken);
const timestamp = now();
const { protected: header, payload, key } = await this.validateJWT(idToken, expectedAlg);
if (maxAge || (maxAge !== null && this.require_auth_time)) {
if (!payload.auth_time) {
throw new RPError({
message: 'missing required JWT property auth_time',
jwt: idToken,
});
}
if (typeof payload.auth_time !== 'number') {
throw new RPError({
message: 'JWT auth_time claim must be a JSON numeric value',
jwt: idToken,
});
}
}
if (maxAge && (payload.auth_time + maxAge < timestamp - this[CLOCK_TOLERANCE])) {
throw new RPError({
printf: ['too much time has elapsed since the last End-User authentication, max_age %i, auth_time: %i, now %i', maxAge, payload.auth_time, timestamp - this[CLOCK_TOLERANCE]],
now: timestamp,
tolerance: this[CLOCK_TOLERANCE],
auth_time: payload.auth_time,
jwt: idToken,
});
}
if (nonce !== null && (payload.nonce || nonce !== undefined) && payload.nonce !== nonce) {
throw new RPError({
printf: ['nonce mismatch, expected %s, got: %s', nonce, payload.nonce],
jwt: idToken,
});
}
const fapi = this.constructor.name === 'FAPIClient';
if (returnedBy === 'authorization') {
if (!payload.at_hash && tokenSet.access_token) {
throw new RPError({
message: 'missing required property at_hash',
jwt: idToken,
});
}
if (!payload.c_hash && tokenSet.code) {
throw new RPError({
message: 'missing required property c_hash',
jwt: idToken,
});
}
if (fapi) {
if (!payload.s_hash && (tokenSet.state || state)) {
throw new RPError({
message: 'missing required property s_hash',
jwt: idToken,
});
}
}
if (payload.s_hash) {
if (!state) {
throw new TypeError('cannot verify s_hash, "checks.state" property not provided');
}
try {
tokenHash.validate({ claim: 's_hash', source: 'state' }, payload.s_hash, state, header.alg, key && key.crv);
} catch (err) {
throw new RPError({ message: err.message, jwt: idToken });
}
}
}
if (fapi && payload.iat < timestamp - 3600) {
throw new RPError({
printf: ['JWT issued too far in the past, now %i, iat %i', timestamp, payload.iat],
now: timestamp,
tolerance: this[CLOCK_TOLERANCE],
iat: payload.iat,
jwt: idToken,
});
}
if (tokenSet.access_token && payload.at_hash !== undefined) {
try {
tokenHash.validate({ claim: 'at_hash', source: 'access_token' }, payload.at_hash, tokenSet.access_token, header.alg, key && key.crv);
} catch (err) {
throw new RPError({ message: err.message, jwt: idToken });
}
}
if (tokenSet.code && payload.c_hash !== undefined) {
try {
tokenHash.validate({ claim: 'c_hash', source: 'code' }, payload.c_hash, tokenSet.code, header.alg, key && key.crv);
} catch (err) {
throw new RPError({ message: err.message, jwt: idToken });
}
}
return tokenSet;
}
/**
* @name validateJWT
* @api private
*/
async validateJWT(jwt, expectedAlg, required = ['iss', 'sub', 'aud', 'exp', 'iat']) {
const isSelfIssued = this.issuer.issuer === 'https://self-issued.me';
const timestamp = now();
let header;
let payload;
try {
({ header, payload } = jose.JWT.decode(jwt, { complete: true }));
} catch (err) {
throw new RPError({
printf: ['failed to decode JWT (%s: %s)', err.name, err.message],
jwt,
});
}
if (header.alg !== expectedAlg) {
throw new RPError({
printf: ['unexpected JWT alg received, expected %s, got: %s', expectedAlg, header.alg],
jwt,
});
}
if (isSelfIssued) {
required = [...required, 'sub_jwk']; // eslint-disable-line no-param-reassign
}
required.forEach(verifyPresence.bind(undefined, payload, jwt));
if (payload.iss !== undefined) {
let expectedIss = this.issuer.issuer;
if (aadIssValidation) {
expectedIss = this.issuer.issuer.replace('{tenantid}', payload.tid);
}
if (payload.iss !== expectedIss) {
throw new RPError({
printf: ['unexpected iss value, expected %s, got: %s', expectedIss, payload.iss],
jwt,
});
}
}
if (payload.iat !== undefined) {
if (typeof payload.iat !== 'number') {
throw new RPError({
message: 'JWT iat claim must be a JSON numeric value',
jwt,
});
}
}
if (payload.nbf !== undefined) {
if (typeof payload.nbf !== 'number') {
throw new RPError({
message: 'JWT nbf claim must be a JSON numeric value',
jwt,
});
}
if (payload.nbf > timestamp + this[CLOCK_TOLERANCE]) {
throw new RPError({
printf: ['JWT not active yet, now %i, nbf %i', timestamp + this[CLOCK_TOLERANCE], payload.nbf],
now: timestamp,
tolerance: this[CLOCK_TOLERANCE],
nbf: payload.nbf,
jwt,
});
}
}
if (payload.exp !== undefined) {
if (typeof payload.exp !== 'number') {
throw new RPError({
message: 'JWT exp claim must be a JSON numeric value',
jwt,
});
}
if (timestamp - this[CLOCK_TOLERANCE] >= payload.exp) {
throw new RPError({
printf: ['JWT expired, now %i, exp %i', timestamp - this[CLOCK_TOLERANCE], payload.exp],
now: timestamp,
tolerance: this[CLOCK_TOLERANCE],
exp: payload.exp,
jwt,
});
}
}
if (payload.aud !== undefined) {
if (Array.isArray(payload.aud)) {
if (payload.aud.length > 1 && !payload.azp) {
throw new RPError({
message: 'missing required JWT property azp',
jwt,
});
}
if (!payload.aud.includes(this.client_id)) {
throw new RPError({
printf: ['aud is missing the client_id, expected %s to be included in %j', this.client_id, payload.aud],
jwt,
});
}
} else if (payload.aud !== this.client_id) {
throw new RPError({
printf: ['aud mismatch, expected %s, got: %s', this.client_id, payload.aud],
jwt,
});
}
}
if (payload.azp !== undefined) {
let { additionalAuthorizedParties } = instance(this).get('options') || {};
if (typeof additionalAuthorizedParties === 'string') {
additionalAuthorizedParties = [this.client_id, additionalAuthorizedParties];
} else if (Array.isArray(additionalAuthorizedParties)) {
additionalAuthorizedParties = [this.client_id, ...additionalAuthorizedParties];
} else {
additionalAuthorizedParties = [this.client_id];
}
if (!additionalAuthorizedParties.includes(payload.azp)) {
throw new RPError({
printf: ['azp mismatch, got: %s', payload.azp],
jwt,
});
}
}
let key;
if (isSelfIssued) {
try {
assert(isPlainObject(payload.sub_jwk));
key = jose.JWK.asKey(payload.sub_jwk);
assert.equal(key.type, 'public');
} catch (err) {
throw new RPError({
message: 'failed to use sub_jwk claim as an asymmetric JSON Web Key',
jwt,
});
}
if (key.thumbprint !== payload.sub) {
throw new RPError({
message: 'failed to match the subject with sub_jwk',
jwt,
});
}
} else if (header.alg.startsWith('HS')) {
key = await this.joseSecret();
} else if (header.alg !== 'none') {
key = await this.issuer.queryKeyStore(header);
}
if (!key && header.alg === 'none') {
return { protected: header, payload };
}
try {
return {
...jose.JWS.verify(jwt, key, { complete: true }),
payload,
};
} catch (err) {
throw new RPError({
message: 'failed to validate JWT signature',
jwt,
});
}
}
/**
* @name refresh
* @api public
*/
async refresh(refreshToken, { exchangeBody, clientAssertionPayload, DPoP } = {}) {
let token = refreshToken;
if (token instanceof TokenSet) {
if (!token.refresh_token) {
throw new TypeError('refresh_token not present in TokenSet');
}
token = token.refresh_token;
}
const tokenset = await this.grant({
...exchangeBody,
grant_type: 'refresh_token',
refresh_token: String(token),
}, { clientAssertionPayload, DPoP });
if (tokenset.id_token) {
await this.decryptIdToken(tokenset);
await this.validateIdToken(tokenset, null, 'token', null);
if (refreshToken instanceof TokenSet && refreshToken.id_token) {
const expectedSub = refreshToken.claims().sub;
const actualSub = tokenset.claims().sub;
if (actualSub !== expectedSub) {
throw new RPError({
printf: ['sub mismatch, expected %s, got: %s', expectedSub, actualSub],
jwt: tokenset.id_token,
});
}
}
}
return tokenset;
}
async requestResource(
resourceUrl,
accessToken,
{
method,
headers,
body,
DPoP,
// eslint-disable-next-line no-nested-ternary
tokenType = DPoP ? 'DPoP' : accessToken instanceof TokenSet ? accessToken.token_type : 'Bearer',
} = {},
) {
if (accessToken instanceof TokenSet) {
if (!accessToken.access_token) {
throw new TypeError('access_token not present in TokenSet');
}
accessToken = accessToken.access_token; // eslint-disable-line no-param-reassign
}
const requestOpts = {
headers: {
Authorization: authorizationHeaderValue(accessToken, tokenType),
...headers,
},
body,
};
const mTLS = !!this.tls_client_certificate_bound_access_tokens;
return request.call(this, {
...requestOpts,
responseType: 'buffer',
method,
url: resourceUrl,
}, { accessToken, mTLS, DPoP });
}
/**
* @name userinfo
* @api public
*/
async userinfo(accessToken, {
method = 'GET', via = 'header', tokenType, params, DPoP,
} = {}) {
assertIssuerConfiguration(this.issuer, 'userinfo_endpoint');
const options = {
tokenType,
method: String(method).toUpperCase(),
DPoP,
};
if (options.method !== 'GET' && options.method !== 'POST') {
throw new TypeError('#userinfo() method can only be POST or a GET');
}
if (via === 'query' && options.method !== 'GET') {
throw new TypeError('userinfo endpoints will only parse query strings for GET requests');
} else if (via === 'body' && options.method !== 'POST') {
throw new TypeError('can only send body on POST');
}
const jwt = !!(this.userinfo_signed_response_alg || this.userinfo_encrypted_response_alg);
if (jwt) {
options.headers = { Accept: 'application/jwt' };
} else {
options.headers = { Accept: 'application/json' };
}
const mTLS = !!this.tls_client_certificate_bound_access_tokens;
let targetUrl;
if (mTLS && this.issuer.mtls_endpoint_aliases) {
targetUrl = this.issuer.mtls_endpoint_aliases.userinfo_endpoint;
}
targetUrl = new url.URL(targetUrl || this.issuer.userinfo_endpoint);
// when via is not header we clear the Authorization header and add either
// query string parameters or urlencoded body access_token parameter
if (via === 'query') {
options.headers.Authorization = undefined;
targetUrl.searchParams.append('access_token', accessToken instanceof TokenSet ? accessToken.access_token : accessToken);
} else if (via === 'body') {
options.headers.Authorization = undefined;
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.body = new url.URLSearchParams();
options.body.append('access_token', accessToken instanceof TokenSet ? accessToken.access_token : accessToken);
}
// handle additional parameters, GET via querystring, POST via urlencoded body
if (params) {
if (options.method === 'GET') {
Object.entries(params).forEach(([key, value]) => {
targetUrl.searchParams.append(key, value);
});
} else if (options.body) { // POST && via body
Object.entries(params).forEach(([key, value]) => {
options.body.append(key, value);
});
} else { // POST && via header
options.body = new url.URLSearchParams();
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
Object.entries(params).forEach(([key, value]) => {
options.body.append(key, value);
});
}
}
if (options.body) {
options.body = options.body.toString();
}
const response = await this.requestResource(targetUrl, accessToken, options);
let parsed = processResponse(response, { bearer: true });
if (jwt) {
if (!JWT_CONTENT.test(response.headers['content-type'])) {
throw new RPError({
message: 'expected application/jwt response from the userinfo_endpoint',
response,
});
}
const body = response.body.toString();
const userinfo = await this.decryptJWTUserinfo(body);
if (!this.userinfo_signed_response_alg) {
try {
parsed = JSON.parse(userinfo);
assert(isPlainObject(parsed));
} catch (err) {
throw new RPError({
message: 'failed to parse userinfo JWE payload as JSON',
jwt: userinfo,
});
}
} else {
({ payload: parsed } = await this.validateJWTUserinfo(userinfo));
}
} else {
try {
parsed = JSON.parse(response.body);
} catch (error) {
throw new ParseError(error, response);
}
}
if (accessToken instanceof TokenSet && accessToken.id_token) {
const expectedSub = accessToken.claims().sub;
if (parsed.sub !== expectedSub) {
throw new RPError({
printf: ['userinfo sub mismatch, expected %s, got: %s', expectedSub, parsed.sub],
body: parsed,
jwt: accessToken.id_token,
});
}
}
return parsed;
}
/**
* @name derivedKey
* @api private
*/
async derivedKey(len) {
const cacheKey = `${len}_key`;
if (instance(this).has(cacheKey)) {
return instance(this).get(cacheKey);
}
const hash = len <= 256 ? 'sha256' : len <= 384 ? 'sha384' : len <= 512 ? 'sha512' : false; // eslint-disable-line no-nested-ternary
if (!hash) {
throw new Error('unsupported symmetric encryption key derivation');
}
const derivedBuffer = crypto.createHash(hash)
.update(this.client_secret)
.digest()
.slice(0, len / 8);
const key = jose.JWK.asKey({ k: base64url.encode(derivedBuffer), kty: 'oct' });
instance(this).set(cacheKey, key);
return key;
}
/**
* @name joseSecret
* @api private
*/
async joseSecret(alg) {
if (!this.client_secret) {
throw new TypeError('client_secret is required');
}
if (/^A(\d{3})(?:GCM)?KW$/.test(alg)) {
return this.derivedKey(parseInt(RegExp.$1, 10));
}
if (/^A(\d{3})(?:GCM|CBC-HS(\d{3}))$/.test(alg)) {
return this.derivedKey(parseInt(RegExp.$2 || RegExp.$1, 10));
}
if (instance(this).has('jose_secret')) {
return instance(this).get('jose_secret');
}
const key = jose.JWK.asKey({ k: base64url.encode(this.client_secret), kty: 'oct' });
instance(this).set('jose_secret', key);
return key;
}
/**
* @name grant
* @api public
*/
async grant(body, { clientAssertionPayload, DPoP } = {}) {
assertIssuerConfiguration(this.issuer, 'token_endpoint');
const response = await authenticatedPost.call(
this,
'token',
{
form: body,
responseType: 'json',
},
{ clientAssertionPayload, DPoP },
);
const responseBody = processResponse(response);
return new TokenSet(responseBody);
}
/**
* @name deviceAuthorization
* @api public
*/
async deviceAuthorization(params = {}, { exchangeBody, clientAssertionPayload, DPoP } = {}) {
assertIssuerConfiguration(this.issuer, 'device_authorization_endpoint');
assertIssuerConfiguration(this.issuer, 'token_endpoint');
const body = authorizationParams.call(this, {
client_id: this.client_id,
redirect_uri: null,
response_type: null,
...params,
});
const response = await authenticatedPost.call(
this,
'device_authorization',
{
responseType: 'json',
form: body,
},
{ clientAssertionPayload, endpointAuthMethod: 'token' },
);
const responseBody = processResponse(response);
return new DeviceFlowHandle({
client: this,
exchangeBody,
clientAssertionPayload,
response: responseBody,
maxAge: params.max_age,
DPoP,
});
}
/**
* @name revoke
* @api public
*/
async revoke(token, hint, { revokeBody, clientAssertionPayload } = {}) {
assertIssuerConfiguration(this.issuer, 'revocation_endpoint');
if (hint !== undefined && typeof hint !== 'string') {
throw new TypeError('hint must be a string');
}
const form = { ...revokeBody, token };
if (hint) {
form.token_type_hint = hint;
}
const response = await authenticatedPost.call(
this,
'revocation', {
form,
}, { clientAssertionPayload },
);
processResponse(response, { body: false });
}
/**
* @name introspect
* @api public
*/
async introspect(token, hint, { introspectBody, clientAssertionPayload } = {}) {
assertIssuerConfiguration(this.issuer, 'introspection_endpoint');
if (hint !== undefined && typeof hint !== 'string') {
throw new TypeError('hint must be a string');
}
const form = { ...introspectBody, token };
if (hint) {
form.token_type_hint = hint;
}
const response = await authenticatedPost.call(
this,
'introspection',
{ form, responseType: 'json' },
{ clientAssertionPayload },
);
const responseBody = processResponse(response);
return responseBody;
}
/**
* @name fetchDistributedClaims
* @api public
*/
async fetchDistributedClaims(claims, tokens = {}) {
if (!isPlainObject(claims)) {
throw new TypeError('claims argument must be a plain object');
}
if (!isPlainObject(claims._claim_sources)) {
return claims;
}
if (!isPlainObject(claims._claim_names)) {
return claims;
}
const distributedSources = Object.entries(claims._claim_sources)
.filter(([, value]) => value && value.endpoint);
await Promise.all(distributedSources.map(async ([sourceName, def]) => {
try {
const requestOpts = {
headers: {
Accept: 'application/jwt',
Authorization: authorizationHeaderValue(def.access_token || tokens[sourceName]),
},
};
const response = await request.call(this, {
...requestOpts,
method: 'GET',
url: def.endpoint,
});
const body = processResponse(response, { bearer: true });
const decoded = await claimJWT.call(this, 'distributed', body);
delete claims._claim_sources[sourceName];
Object.entries(claims._claim_names).forEach(
assignClaim(claims, decoded, sourceName, false),
);
} catch (err) {
err.src = sourceName;
throw err;
}
}));
cleanUpClaims(claims);
return claims;
}
/**
* @name unpackAggregatedClaims
* @api public
*/
async unpackAggregatedClaims(claims) {
if (!isPlainObject(claims)) {
throw new TypeError('claims argument must be a plain object');
}
if (!isPlainObject(claims._claim_sources)) {
return claims;
}
if (!isPlainObject(claims._claim_names)) {
return claims;
}
const aggregatedSources = Object.entries(claims._claim_sources)
.filter(([, value]) => value && value.JWT);
await Promise.all(aggregatedSources.map(async ([sourceName, def]) => {
try {
const decoded = await claimJWT.call(this, 'aggregated', def.JWT);
delete claims._claim_sources[sourceName];
Object.entries(claims._claim_names).forEach(assignClaim(claims, decoded, sourceName));
} catch (err) {
err.src = sourceName;
throw err;
}
}));
cleanUpClaims(claims);
return claims;
}
/**
* @name register
* @api public
*/
static async register(metadata, options = {}) {
const { initialAccessToken, jwks, ...clientOptions } = options;
assertIssuerConfiguration(this.issuer, 'registration_endpoint');
if (jwks !== undefined && !(metadata.jwks || metadata.jwks_uri)) {
const keystore = getKeystore.call(this, jwks);
metadata.jwks = keystore.toJWKS(false);
// eslint-disable-next-line no-restricted-syntax
for (const jwk of metadata.jwks.keys) {
if (jwk.kid.startsWith('DONOTUSE.')) {
delete jwk.kid;
}
}
}
const response = await request.call(this, {
headers: initialAccessToken ? {
Authorization: authorizationHeaderValue(initialAccessToken),
} : undefined,
responseType: 'json',
json: metadata,
url: this.issuer.registration_endpoint,
method: 'POST',
});
const responseBody = processResponse(response, { statusCode: 201, bearer: true });
return new this(responseBody, jwks, clientOptions);
}
/**
* @name metadata
* @api public
*/
get metadata() {
const copy = {};
instance(this).get('metadata').forEach((value, key) => {
copy[key] = value;
});
return copy;
}
/**
* @name fromUri
* @api public
*/
static async fromUri(registrationClientUri, registrationAccessToken, jwks, clientOptions) {
const response = await request.call(this, {
method: 'GET',
url: registrationClientUri,
responseType: 'json',
headers: { Authorization: authorizationHeaderValue(registrationAccessToken) },
});
const responseBody = processResponse(response, { bearer: true });
return new this(responseBody, jwks, clientOptions);
}
/**
* @name requestObject
* @api public
*/
async requestObject(requestObject = {}, {
sign: signingAlgorithm = this.request_object_signing_alg || 'none',
encrypt: {
alg: eKeyManagement = this.request_object_encryption_alg,
enc: eContentEncryption = this.request_object_encryption_enc || 'A128CBC-HS256',
} = {},
} = {}) {
if (!isPlainObject(requestObject)) {
throw new TypeError('requestObject must be a plain object');
}
let signed;
let key;
const fapi = this.constructor.name === 'FAPIClient';
const unix = now();
const header = { alg: signingAlgorithm, typ: 'oauth-authz-req+jwt' };
const payload = JSON.stringify(defaults({}, requestObject, {
iss: this.client_id,
aud: this.issuer.issuer,
client_id: this.client_id,
jti: random(),
iat: unix,
exp: unix + 300,
...(fapi ? { nbf: unix } : undefined),
}));
if (signingAlgorithm === 'none') {
signed = [
base64url.encode(JSON.stringify(header)),
base64url.encode(payload),
'',
].join('.');
} else {
const symmetric = signingAlgorithm.startsWith('HS');
if (symmetric) {
key = await this.joseSecret();
} else {
const keystore = instance(this).get('keystore');
if (!keystore) {
throw new TypeError(`no keystore present for client, cannot sign using alg ${signingAlgorithm}`);
}
key = keystore.get({ alg: signingAlgorithm, use: 'sig' });
if (!key) {
throw new TypeError(`no key to sign with found for alg ${signingAlgorithm}`);
}
}
signed = jose.JWS.sign(payload, key, {
...header,
kid: symmetric || key.kid.startsWith('DONOTUSE.') ? undefined : key.kid,
});
}
if (!eKeyManagement) {
return signed;
}
const fields = { alg: eKeyManagement, enc: eContentEncryption, cty: 'oauth-authz-req+jwt' };
if (fields.alg.match(/^(RSA|ECDH)/)) {
[key] = await this.issuer.queryKeyStore({
alg: fields.alg,
enc: fields.enc,
use: 'enc',
}, { allowMulti: true });
} else {
key = await this.joseSecret(fields.alg === 'dir' ? fields.enc : fields.alg);
}
return jose.JWE.encrypt(signed, key, {
...fields,
kid: key.kty === 'oct' ? undefined : key.kid,
});
}
/**
* @name pushedAuthorizationRequest
* @api public
*/
async pushedAuthorizationRequest(params = {}, { clientAssertionPayload } = {}) {
assertIssuerConfiguration(this.issuer, 'pushed_authorization_request_endpoint');
const body = {
...('request' in params ? params : authorizationParams.call(this, params)),
client_id: this.client_id,
};
const response = await authenticatedPost.call(
this,
'pushed_authorization_request',
{
responseType: 'json',
form: body,
},
{ clientAssertionPayload, endpointAuthMethod: 'token' },
);
const responseBody = processResponse(response, { statusCode: 201 });
if (!('expires_in' in responseBody)) {
throw new RPError({
message: 'expected expires_in in Pushed Authorization Successful Response',
response,
});
}
if (typeof responseBody.expires_in !== 'number') {
throw new RPError({
message: 'invalid expires_in value in Pushed Authorization Successful Response',
response,
});
}
if (!('request_uri' in responseBody)) {
throw new RPError({
message: 'expected request_uri in Pushed Authorization Successful Response',
response,
});
}
if (typeof responseBody.request_uri !== 'string') {
throw new RPError({
message: 'invalid request_uri value in Pushed Authorization Successful Response',
response,
});
}
return responseBody;
}
/**
* @name issuer
* @api public
*/
static get issuer() {
return issuer;
}
/**
* @name issuer
* @api public
*/
get issuer() { // eslint-disable-line class-methods-use-this
return issuer;
}
/* istanbul ignore next */
[inspect.custom]() {
return `${this.constructor.name} ${inspect(this.metadata, {
depth: Infinity,
colors: process.stdout.isTTY,
compact: false,
sorted: true,
})}`;
}
};
/**
* @name validateJARM
* @api private
*/
async function validateJARM(response) {
const expectedAlg = this.authorization_signed_response_alg;
const { payload } = await this.validateJWT(response, expectedAlg, ['iss', 'exp', 'aud']);
return pickCb(payload);
}
Object.defineProperty(BaseClient.prototype, 'validateJARM', {
enumerable: true,
configurable: true,
value(...args) {
process.emitWarning(
"The JARM API implements an OIDF implementer's draft. Breaking draft implementations are included as minor versions of the openid-client library, therefore, the ~ semver operator should be used and close attention be payed to library changelog as well as the drafts themselves.",
'DraftWarning',
);
Object.defineProperty(BaseClient.prototype, 'validateJARM', {
enumerable: true,
configurable: true,
value: validateJARM,
});
return this.validateJARM(...args);
},
});
/**
* @name dpopProof
* @api private
*/
function dpopProof(payload, jwk, accessToken) {
if (!isPlainObject(payload)) {
throw new TypeError('payload must be a plain object');
}
let key;
try {
key = jose.JWK.asKey(jwk);
assert(key.type === 'private');
} catch (err) {
throw new TypeError('"DPoP" option must be an asymmetric private key to sign the DPoP Proof JWT with');
}
let { alg } = key;
if (!alg && this.issuer.dpop_signing_alg_values_supported) {
const algs = key.algorithms('sign');
alg = this.issuer.dpop_signing_alg_values_supported.find((a) => algs.has(a));
}
if (!alg) {
[alg] = key.algorithms('sign');
}
return jose.JWS.sign({
iat: now(),
jti: random(),
ath: accessToken ? base64url.encode(crypto.createHash('sha256').update(accessToken).digest()) : undefined,
...payload,
}, jwk, {
alg,
typ: 'dpop+jwt',
jwk: pick(key, 'kty', 'crv', 'x', 'y', 'e', 'n'),
});
}
Object.defineProperty(BaseClient.prototype, 'dpopProof', {
enumerable: true,
configurable: true,
value(...args) {
process.emitWarning(
'The DPoP APIs implements an IETF draft (https://www.ietf.org/archive/id/draft-ietf-oauth-dpop-03.html). Breaking draft implementations are included as minor versions of the openid-client library, therefore, the ~ semver operator should be used and close attention be payed to library changelog as well as the drafts themselves.',
'DraftWarning',
);
Object.defineProperty(BaseClient.prototype, 'dpopProof', {
enumerable: true,
configurable: true,
value: dpopProof,
});
return this.dpopProof(...args);
},
});
module.exports.BaseClient = BaseClient;