2505 lines
97 KiB
JavaScript
2505 lines
97 KiB
JavaScript
let USER_AGENT;
|
|
if (typeof navigator === 'undefined' || !navigator.userAgent?.startsWith?.('Mozilla/5.0 ')) {
|
|
const NAME = 'oauth4webapi';
|
|
const VERSION = 'v3.8.3';
|
|
USER_AGENT = `${NAME}/${VERSION}`;
|
|
}
|
|
function looseInstanceOf(input, expected) {
|
|
if (input == null) {
|
|
return false;
|
|
}
|
|
try {
|
|
return (input instanceof expected ||
|
|
Object.getPrototypeOf(input)[Symbol.toStringTag] === expected.prototype[Symbol.toStringTag]);
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
const ERR_INVALID_ARG_VALUE = 'ERR_INVALID_ARG_VALUE';
|
|
const ERR_INVALID_ARG_TYPE = 'ERR_INVALID_ARG_TYPE';
|
|
function CodedTypeError(message, code, cause) {
|
|
const err = new TypeError(message, { cause });
|
|
Object.assign(err, { code });
|
|
return err;
|
|
}
|
|
export const allowInsecureRequests = Symbol();
|
|
export const clockSkew = Symbol();
|
|
export const clockTolerance = Symbol();
|
|
export const customFetch = Symbol();
|
|
export const modifyAssertion = Symbol();
|
|
export const jweDecrypt = Symbol();
|
|
export const jwksCache = Symbol();
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
function buf(input) {
|
|
if (typeof input === 'string') {
|
|
return encoder.encode(input);
|
|
}
|
|
return decoder.decode(input);
|
|
}
|
|
let encodeBase64Url;
|
|
if (Uint8Array.prototype.toBase64) {
|
|
encodeBase64Url = (input) => {
|
|
if (input instanceof ArrayBuffer) {
|
|
input = new Uint8Array(input);
|
|
}
|
|
return input.toBase64({ alphabet: 'base64url', omitPadding: true });
|
|
};
|
|
}
|
|
else {
|
|
const CHUNK_SIZE = 0x8000;
|
|
encodeBase64Url = (input) => {
|
|
if (input instanceof ArrayBuffer) {
|
|
input = new Uint8Array(input);
|
|
}
|
|
const arr = [];
|
|
for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) {
|
|
arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)));
|
|
}
|
|
return btoa(arr.join('')).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
};
|
|
}
|
|
let decodeBase64Url;
|
|
if (Uint8Array.fromBase64) {
|
|
decodeBase64Url = (input) => {
|
|
try {
|
|
return Uint8Array.fromBase64(input, { alphabet: 'base64url' });
|
|
}
|
|
catch (cause) {
|
|
throw CodedTypeError('The input to be decoded is not correctly encoded.', ERR_INVALID_ARG_VALUE, cause);
|
|
}
|
|
};
|
|
}
|
|
else {
|
|
decodeBase64Url = (input) => {
|
|
try {
|
|
const binary = atob(input.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
catch (cause) {
|
|
throw CodedTypeError('The input to be decoded is not correctly encoded.', ERR_INVALID_ARG_VALUE, cause);
|
|
}
|
|
};
|
|
}
|
|
function b64u(input) {
|
|
if (typeof input === 'string') {
|
|
return decodeBase64Url(input);
|
|
}
|
|
return encodeBase64Url(input);
|
|
}
|
|
export class UnsupportedOperationError extends Error {
|
|
code;
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = this.constructor.name;
|
|
this.code = UNSUPPORTED_OPERATION;
|
|
Error.captureStackTrace?.(this, this.constructor);
|
|
}
|
|
}
|
|
export class OperationProcessingError extends Error {
|
|
code;
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = this.constructor.name;
|
|
if (options?.code) {
|
|
this.code = options?.code;
|
|
}
|
|
Error.captureStackTrace?.(this, this.constructor);
|
|
}
|
|
}
|
|
function OPE(message, code, cause) {
|
|
return new OperationProcessingError(message, { code, cause });
|
|
}
|
|
async function calculateJwkThumbprint(jwk) {
|
|
let components;
|
|
switch (jwk.kty) {
|
|
case 'EC':
|
|
components = {
|
|
crv: jwk.crv,
|
|
kty: jwk.kty,
|
|
x: jwk.x,
|
|
y: jwk.y,
|
|
};
|
|
break;
|
|
case 'OKP':
|
|
components = {
|
|
crv: jwk.crv,
|
|
kty: jwk.kty,
|
|
x: jwk.x,
|
|
};
|
|
break;
|
|
case 'AKP':
|
|
components = {
|
|
alg: jwk.alg,
|
|
kty: jwk.kty,
|
|
pub: jwk.pub,
|
|
};
|
|
break;
|
|
case 'RSA':
|
|
components = {
|
|
e: jwk.e,
|
|
kty: jwk.kty,
|
|
n: jwk.n,
|
|
};
|
|
break;
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported JWK key type', { cause: jwk });
|
|
}
|
|
return b64u(await crypto.subtle.digest('SHA-256', buf(JSON.stringify(components))));
|
|
}
|
|
function assertCryptoKey(key, it) {
|
|
if (!(key instanceof CryptoKey)) {
|
|
throw CodedTypeError(`${it} must be a CryptoKey`, ERR_INVALID_ARG_TYPE);
|
|
}
|
|
}
|
|
function assertPrivateKey(key, it) {
|
|
assertCryptoKey(key, it);
|
|
if (key.type !== 'private') {
|
|
throw CodedTypeError(`${it} must be a private CryptoKey`, ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
function assertPublicKey(key, it) {
|
|
assertCryptoKey(key, it);
|
|
if (key.type !== 'public') {
|
|
throw CodedTypeError(`${it} must be a public CryptoKey`, ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
function normalizeTyp(value) {
|
|
return value.toLowerCase().replace(/^application\//, '');
|
|
}
|
|
function isJsonObject(input) {
|
|
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
function prepareHeaders(input) {
|
|
if (looseInstanceOf(input, Headers)) {
|
|
input = Object.fromEntries(input.entries());
|
|
}
|
|
const headers = new Headers(input ?? {});
|
|
if (USER_AGENT && !headers.has('user-agent')) {
|
|
headers.set('user-agent', USER_AGENT);
|
|
}
|
|
if (headers.has('authorization')) {
|
|
throw CodedTypeError('"options.headers" must not include the "authorization" header name', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
return headers;
|
|
}
|
|
function signal(url, value) {
|
|
if (value !== undefined) {
|
|
if (typeof value === 'function') {
|
|
value = value(url.href);
|
|
}
|
|
if (!(value instanceof AbortSignal)) {
|
|
throw CodedTypeError('"options.signal" must return or be an instance of AbortSignal', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
function replaceDoubleSlash(pathname) {
|
|
if (pathname.includes('//')) {
|
|
return pathname.replace('//', '/');
|
|
}
|
|
return pathname;
|
|
}
|
|
function prependWellKnown(url, wellKnown, allowTerminatingSlash = false) {
|
|
if (url.pathname === '/') {
|
|
url.pathname = wellKnown;
|
|
}
|
|
else {
|
|
url.pathname = replaceDoubleSlash(`${wellKnown}/${allowTerminatingSlash ? url.pathname : url.pathname.replace(/(\/)$/, '')}`);
|
|
}
|
|
return url;
|
|
}
|
|
function appendWellKnown(url, wellKnown) {
|
|
url.pathname = replaceDoubleSlash(`${url.pathname}/${wellKnown}`);
|
|
return url;
|
|
}
|
|
async function performDiscovery(input, urlName, transform, options) {
|
|
if (!(input instanceof URL)) {
|
|
throw CodedTypeError(`"${urlName}" must be an instance of URL`, ERR_INVALID_ARG_TYPE);
|
|
}
|
|
checkProtocol(input, options?.[allowInsecureRequests] !== true);
|
|
const url = transform(new URL(input.href));
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
return (options?.[customFetch] || fetch)(url.href, {
|
|
body: undefined,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
signal: signal(url, options?.signal),
|
|
});
|
|
}
|
|
export async function discoveryRequest(issuerIdentifier, options) {
|
|
return performDiscovery(issuerIdentifier, 'issuerIdentifier', (url) => {
|
|
switch (options?.algorithm) {
|
|
case undefined:
|
|
case 'oidc':
|
|
appendWellKnown(url, '.well-known/openid-configuration');
|
|
break;
|
|
case 'oauth2':
|
|
prependWellKnown(url, '.well-known/oauth-authorization-server');
|
|
break;
|
|
default:
|
|
throw CodedTypeError('"options.algorithm" must be "oidc" (default), or "oauth2"', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
return url;
|
|
}, options);
|
|
}
|
|
function assertNumber(input, allow0, it, code, cause) {
|
|
try {
|
|
if (typeof input !== 'number' || !Number.isFinite(input)) {
|
|
throw CodedTypeError(`${it} must be a number`, ERR_INVALID_ARG_TYPE, cause);
|
|
}
|
|
if (input > 0)
|
|
return;
|
|
if (allow0) {
|
|
if (input !== 0) {
|
|
throw CodedTypeError(`${it} must be a non-negative number`, ERR_INVALID_ARG_VALUE, cause);
|
|
}
|
|
return;
|
|
}
|
|
throw CodedTypeError(`${it} must be a positive number`, ERR_INVALID_ARG_VALUE, cause);
|
|
}
|
|
catch (err) {
|
|
if (code) {
|
|
throw OPE(err.message, code, cause);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
function assertString(input, it, code, cause) {
|
|
try {
|
|
if (typeof input !== 'string') {
|
|
throw CodedTypeError(`${it} must be a string`, ERR_INVALID_ARG_TYPE, cause);
|
|
}
|
|
if (input.length === 0) {
|
|
throw CodedTypeError(`${it} must not be empty`, ERR_INVALID_ARG_VALUE, cause);
|
|
}
|
|
}
|
|
catch (err) {
|
|
if (code) {
|
|
throw OPE(err.message, code, cause);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
export async function processDiscoveryResponse(expectedIssuerIdentifier, response) {
|
|
const expected = expectedIssuerIdentifier;
|
|
if (!(expected instanceof URL) && expected !== _nodiscoverycheck) {
|
|
throw CodedTypeError('"expectedIssuerIdentifier" must be an instance of URL', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (response.status !== 200) {
|
|
throw OPE('"response" is not a conform Authorization Server Metadata response (unexpected HTTP status code)', RESPONSE_IS_NOT_CONFORM, response);
|
|
}
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.issuer, '"response" body "issuer" property', INVALID_RESPONSE, { body: json });
|
|
if (expected !== _nodiscoverycheck && new URL(json.issuer).href !== expected.href) {
|
|
throw OPE('"response" body "issuer" property does not match the expected value', JSON_ATTRIBUTE_COMPARISON, { expected: expected.href, body: json, attribute: 'issuer' });
|
|
}
|
|
return json;
|
|
}
|
|
function assertApplicationJson(response) {
|
|
assertContentType(response, 'application/json');
|
|
}
|
|
function notJson(response, ...types) {
|
|
let msg = '"response" content-type must be ';
|
|
if (types.length > 2) {
|
|
const last = types.pop();
|
|
msg += `${types.join(', ')}, or ${last}`;
|
|
}
|
|
else if (types.length === 2) {
|
|
msg += `${types[0]} or ${types[1]}`;
|
|
}
|
|
else {
|
|
msg += types[0];
|
|
}
|
|
return OPE(msg, RESPONSE_IS_NOT_JSON, response);
|
|
}
|
|
function assertContentTypes(response, ...types) {
|
|
if (!types.includes(getContentType(response))) {
|
|
throw notJson(response, ...types);
|
|
}
|
|
}
|
|
function assertContentType(response, contentType) {
|
|
if (getContentType(response) !== contentType) {
|
|
throw notJson(response, contentType);
|
|
}
|
|
}
|
|
function randomBytes() {
|
|
return b64u(crypto.getRandomValues(new Uint8Array(32)));
|
|
}
|
|
export function generateRandomCodeVerifier() {
|
|
return randomBytes();
|
|
}
|
|
export function generateRandomState() {
|
|
return randomBytes();
|
|
}
|
|
export function generateRandomNonce() {
|
|
return randomBytes();
|
|
}
|
|
export async function calculatePKCECodeChallenge(codeVerifier) {
|
|
assertString(codeVerifier, 'codeVerifier');
|
|
return b64u(await crypto.subtle.digest('SHA-256', buf(codeVerifier)));
|
|
}
|
|
function getKeyAndKid(input) {
|
|
if (input instanceof CryptoKey) {
|
|
return { key: input };
|
|
}
|
|
if (!(input?.key instanceof CryptoKey)) {
|
|
return {};
|
|
}
|
|
if (input.kid !== undefined) {
|
|
assertString(input.kid, '"kid"');
|
|
}
|
|
return {
|
|
key: input.key,
|
|
kid: input.kid,
|
|
};
|
|
}
|
|
function psAlg(key) {
|
|
switch (key.algorithm.hash.name) {
|
|
case 'SHA-256':
|
|
return 'PS256';
|
|
case 'SHA-384':
|
|
return 'PS384';
|
|
case 'SHA-512':
|
|
return 'PS512';
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported RsaHashedKeyAlgorithm hash name', {
|
|
cause: key,
|
|
});
|
|
}
|
|
}
|
|
function rsAlg(key) {
|
|
switch (key.algorithm.hash.name) {
|
|
case 'SHA-256':
|
|
return 'RS256';
|
|
case 'SHA-384':
|
|
return 'RS384';
|
|
case 'SHA-512':
|
|
return 'RS512';
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported RsaHashedKeyAlgorithm hash name', {
|
|
cause: key,
|
|
});
|
|
}
|
|
}
|
|
function esAlg(key) {
|
|
switch (key.algorithm.namedCurve) {
|
|
case 'P-256':
|
|
return 'ES256';
|
|
case 'P-384':
|
|
return 'ES384';
|
|
case 'P-521':
|
|
return 'ES512';
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported EcKeyAlgorithm namedCurve', { cause: key });
|
|
}
|
|
}
|
|
function keyToJws(key) {
|
|
switch (key.algorithm.name) {
|
|
case 'RSA-PSS':
|
|
return psAlg(key);
|
|
case 'RSASSA-PKCS1-v1_5':
|
|
return rsAlg(key);
|
|
case 'ECDSA':
|
|
return esAlg(key);
|
|
case 'Ed25519':
|
|
case 'ML-DSA-44':
|
|
case 'ML-DSA-65':
|
|
case 'ML-DSA-87':
|
|
return key.algorithm.name;
|
|
case 'EdDSA':
|
|
return 'Ed25519';
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported CryptoKey algorithm name', { cause: key });
|
|
}
|
|
}
|
|
function getClockSkew(client) {
|
|
const skew = client?.[clockSkew];
|
|
return typeof skew === 'number' && Number.isFinite(skew) ? skew : 0;
|
|
}
|
|
function getClockTolerance(client) {
|
|
const tolerance = client?.[clockTolerance];
|
|
return typeof tolerance === 'number' && Number.isFinite(tolerance) && Math.sign(tolerance) !== -1
|
|
? tolerance
|
|
: 30;
|
|
}
|
|
function epochTime() {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
function assertAs(as) {
|
|
if (typeof as !== 'object' || as === null) {
|
|
throw CodedTypeError('"as" must be an object', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
assertString(as.issuer, '"as.issuer"');
|
|
}
|
|
function assertClient(client) {
|
|
if (typeof client !== 'object' || client === null) {
|
|
throw CodedTypeError('"client" must be an object', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
assertString(client.client_id, '"client.client_id"');
|
|
}
|
|
function formUrlEncode(token) {
|
|
return encodeURIComponent(token).replace(/(?:[-_.!~*'()]|%20)/g, (substring) => {
|
|
switch (substring) {
|
|
case '-':
|
|
case '_':
|
|
case '.':
|
|
case '!':
|
|
case '~':
|
|
case '*':
|
|
case "'":
|
|
case '(':
|
|
case ')':
|
|
return `%${substring.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
case '%20':
|
|
return '+';
|
|
default:
|
|
throw new Error();
|
|
}
|
|
});
|
|
}
|
|
export function ClientSecretPost(clientSecret) {
|
|
assertString(clientSecret, '"clientSecret"');
|
|
return (_as, client, body, _headers) => {
|
|
body.set('client_id', client.client_id);
|
|
body.set('client_secret', clientSecret);
|
|
};
|
|
}
|
|
export function ClientSecretBasic(clientSecret) {
|
|
assertString(clientSecret, '"clientSecret"');
|
|
return (_as, client, _body, headers) => {
|
|
const username = formUrlEncode(client.client_id);
|
|
const password = formUrlEncode(clientSecret);
|
|
const credentials = btoa(`${username}:${password}`);
|
|
headers.set('authorization', `Basic ${credentials}`);
|
|
};
|
|
}
|
|
function clientAssertionPayload(as, client) {
|
|
const now = epochTime() + getClockSkew(client);
|
|
return {
|
|
jti: randomBytes(),
|
|
aud: as.issuer,
|
|
exp: now + 60,
|
|
iat: now,
|
|
nbf: now,
|
|
iss: client.client_id,
|
|
sub: client.client_id,
|
|
};
|
|
}
|
|
export function PrivateKeyJwt(clientPrivateKey, options) {
|
|
const { key, kid } = getKeyAndKid(clientPrivateKey);
|
|
assertPrivateKey(key, '"clientPrivateKey.key"');
|
|
return async (as, client, body, _headers) => {
|
|
const header = { alg: keyToJws(key), kid };
|
|
const payload = clientAssertionPayload(as, client);
|
|
options?.[modifyAssertion]?.(header, payload);
|
|
body.set('client_id', client.client_id);
|
|
body.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
body.set('client_assertion', await signJwt(header, payload, key));
|
|
};
|
|
}
|
|
export function ClientSecretJwt(clientSecret, options) {
|
|
assertString(clientSecret, '"clientSecret"');
|
|
const modify = options?.[modifyAssertion];
|
|
let key;
|
|
return async (as, client, body, _headers) => {
|
|
key ||= await crypto.subtle.importKey('raw', buf(clientSecret), { hash: 'SHA-256', name: 'HMAC' }, false, ['sign']);
|
|
const header = { alg: 'HS256' };
|
|
const payload = clientAssertionPayload(as, client);
|
|
modify?.(header, payload);
|
|
const data = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(payload)))}`;
|
|
const hmac = await crypto.subtle.sign(key.algorithm, key, buf(data));
|
|
body.set('client_id', client.client_id);
|
|
body.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
body.set('client_assertion', `${data}.${b64u(new Uint8Array(hmac))}`);
|
|
};
|
|
}
|
|
export function None() {
|
|
return (_as, client, body, _headers) => {
|
|
body.set('client_id', client.client_id);
|
|
};
|
|
}
|
|
export function TlsClientAuth() {
|
|
return None();
|
|
}
|
|
async function signJwt(header, payload, key) {
|
|
if (!key.usages.includes('sign')) {
|
|
throw CodedTypeError('CryptoKey instances used for signing assertions must include "sign" in their "usages"', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(payload)))}`;
|
|
const signature = b64u(await crypto.subtle.sign(keyToSubtle(key), key, buf(input)));
|
|
return `${input}.${signature}`;
|
|
}
|
|
export async function issueRequestObject(as, client, parameters, privateKey, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
parameters = new URLSearchParams(parameters);
|
|
const { key, kid } = getKeyAndKid(privateKey);
|
|
assertPrivateKey(key, '"privateKey.key"');
|
|
parameters.set('client_id', client.client_id);
|
|
const now = epochTime() + getClockSkew(client);
|
|
const claims = {
|
|
...Object.fromEntries(parameters.entries()),
|
|
jti: randomBytes(),
|
|
aud: as.issuer,
|
|
exp: now + 60,
|
|
iat: now,
|
|
nbf: now,
|
|
iss: client.client_id,
|
|
};
|
|
let resource;
|
|
if (parameters.has('resource') &&
|
|
(resource = parameters.getAll('resource')) &&
|
|
resource.length > 1) {
|
|
claims.resource = resource;
|
|
}
|
|
{
|
|
let value = parameters.get('max_age');
|
|
if (value !== null) {
|
|
claims.max_age = parseInt(value, 10);
|
|
assertNumber(claims.max_age, true, '"max_age" parameter');
|
|
}
|
|
}
|
|
{
|
|
let value = parameters.get('claims');
|
|
if (value !== null) {
|
|
try {
|
|
claims.claims = JSON.parse(value);
|
|
}
|
|
catch (cause) {
|
|
throw OPE('failed to parse the "claims" parameter as JSON', PARSE_ERROR, cause);
|
|
}
|
|
if (!isJsonObject(claims.claims)) {
|
|
throw CodedTypeError('"claims" parameter must be a JSON with a top level object', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
}
|
|
{
|
|
let value = parameters.get('authorization_details');
|
|
if (value !== null) {
|
|
try {
|
|
claims.authorization_details = JSON.parse(value);
|
|
}
|
|
catch (cause) {
|
|
throw OPE('failed to parse the "authorization_details" parameter as JSON', PARSE_ERROR, cause);
|
|
}
|
|
if (!Array.isArray(claims.authorization_details)) {
|
|
throw CodedTypeError('"authorization_details" parameter must be a JSON with a top level array', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
}
|
|
const header = {
|
|
alg: keyToJws(key),
|
|
typ: 'oauth-authz-req+jwt',
|
|
kid,
|
|
};
|
|
options?.[modifyAssertion]?.(header, claims);
|
|
return signJwt(header, claims, key);
|
|
}
|
|
let jwkCache;
|
|
async function getSetPublicJwkCache(key, alg) {
|
|
const { kty, e, n, x, y, crv, pub } = await crypto.subtle.exportKey('jwk', key);
|
|
const jwk = { kty, e, n, x, y, crv, pub };
|
|
if (kty === 'AKP')
|
|
jwk.alg = alg;
|
|
jwkCache.set(key, jwk);
|
|
return jwk;
|
|
}
|
|
async function publicJwk(key, alg) {
|
|
jwkCache ||= new WeakMap();
|
|
return jwkCache.get(key) || getSetPublicJwkCache(key, alg);
|
|
}
|
|
const URLParse = URL.parse
|
|
?
|
|
(url, base) => URL.parse(url, base)
|
|
: (url, base) => {
|
|
try {
|
|
return new URL(url, base);
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
};
|
|
export function checkProtocol(url, enforceHttps) {
|
|
if (enforceHttps && url.protocol !== 'https:') {
|
|
throw OPE('only requests to HTTPS are allowed', HTTP_REQUEST_FORBIDDEN, url);
|
|
}
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
throw OPE('only HTTP and HTTPS requests are allowed', REQUEST_PROTOCOL_FORBIDDEN, url);
|
|
}
|
|
}
|
|
function validateEndpoint(value, endpoint, useMtlsAlias, enforceHttps) {
|
|
let url;
|
|
if (typeof value !== 'string' || !(url = URLParse(value))) {
|
|
throw OPE(`authorization server metadata does not contain a valid ${useMtlsAlias ? `"as.mtls_endpoint_aliases.${endpoint}"` : `"as.${endpoint}"`}`, value === undefined ? MISSING_SERVER_METADATA : INVALID_SERVER_METADATA, { attribute: useMtlsAlias ? `mtls_endpoint_aliases.${endpoint}` : endpoint });
|
|
}
|
|
checkProtocol(url, enforceHttps);
|
|
return url;
|
|
}
|
|
export function resolveEndpoint(as, endpoint, useMtlsAlias, enforceHttps) {
|
|
if (useMtlsAlias && as.mtls_endpoint_aliases && endpoint in as.mtls_endpoint_aliases) {
|
|
return validateEndpoint(as.mtls_endpoint_aliases[endpoint], endpoint, useMtlsAlias, enforceHttps);
|
|
}
|
|
return validateEndpoint(as[endpoint], endpoint, useMtlsAlias, enforceHttps);
|
|
}
|
|
export async function pushedAuthorizationRequest(as, client, clientAuthentication, parameters, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
const url = resolveEndpoint(as, 'pushed_authorization_request_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const body = new URLSearchParams(parameters);
|
|
body.set('client_id', client.client_id);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
if (options?.DPoP !== undefined) {
|
|
assertDPoP(options.DPoP);
|
|
await options.DPoP.addProof(url, headers, 'POST');
|
|
}
|
|
const response = await authenticatedRequest(as, client, clientAuthentication, url, body, headers, options);
|
|
options?.DPoP?.cacheNonce(response, url);
|
|
return response;
|
|
}
|
|
class DPoPHandler {
|
|
#header;
|
|
#privateKey;
|
|
#publicKey;
|
|
#clockSkew;
|
|
#modifyAssertion;
|
|
#map;
|
|
#jkt;
|
|
constructor(client, keyPair, options) {
|
|
assertPrivateKey(keyPair?.privateKey, '"DPoP.privateKey"');
|
|
assertPublicKey(keyPair?.publicKey, '"DPoP.publicKey"');
|
|
if (!keyPair.publicKey.extractable) {
|
|
throw CodedTypeError('"DPoP.publicKey.extractable" must be true', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
this.#modifyAssertion = options?.[modifyAssertion];
|
|
this.#clockSkew = getClockSkew(client);
|
|
this.#privateKey = keyPair.privateKey;
|
|
this.#publicKey = keyPair.publicKey;
|
|
branded.add(this);
|
|
}
|
|
#get(key) {
|
|
this.#map ||= new Map();
|
|
let item = this.#map.get(key);
|
|
if (item) {
|
|
this.#map.delete(key);
|
|
this.#map.set(key, item);
|
|
}
|
|
return item;
|
|
}
|
|
#set(key, val) {
|
|
this.#map ||= new Map();
|
|
this.#map.delete(key);
|
|
if (this.#map.size === 100) {
|
|
this.#map.delete(this.#map.keys().next().value);
|
|
}
|
|
this.#map.set(key, val);
|
|
}
|
|
async calculateThumbprint() {
|
|
if (!this.#jkt) {
|
|
const jwk = await crypto.subtle.exportKey('jwk', this.#publicKey);
|
|
this.#jkt ||= await calculateJwkThumbprint(jwk);
|
|
}
|
|
return this.#jkt;
|
|
}
|
|
async addProof(url, headers, htm, accessToken) {
|
|
const alg = keyToJws(this.#privateKey);
|
|
this.#header ||= {
|
|
alg,
|
|
typ: 'dpop+jwt',
|
|
jwk: await publicJwk(this.#publicKey, alg),
|
|
};
|
|
const nonce = this.#get(url.origin);
|
|
const now = epochTime() + this.#clockSkew;
|
|
const payload = {
|
|
iat: now,
|
|
jti: randomBytes(),
|
|
htm,
|
|
nonce,
|
|
htu: `${url.origin}${url.pathname}`,
|
|
ath: accessToken
|
|
? b64u(await crypto.subtle.digest('SHA-256', buf(accessToken)))
|
|
: undefined,
|
|
};
|
|
this.#modifyAssertion?.(this.#header, payload);
|
|
headers.set('dpop', await signJwt(this.#header, payload, this.#privateKey));
|
|
}
|
|
cacheNonce(response, url) {
|
|
try {
|
|
const nonce = response.headers.get('dpop-nonce');
|
|
if (nonce) {
|
|
this.#set(url.origin, nonce);
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
export function isDPoPNonceError(err) {
|
|
if (err instanceof WWWAuthenticateChallengeError) {
|
|
const { 0: challenge, length } = err.cause;
|
|
return (length === 1 && challenge.scheme === 'dpop' && challenge.parameters.error === 'use_dpop_nonce');
|
|
}
|
|
if (err instanceof ResponseBodyError) {
|
|
return err.error === 'use_dpop_nonce';
|
|
}
|
|
return false;
|
|
}
|
|
export function DPoP(client, keyPair, options) {
|
|
return new DPoPHandler(client, keyPair, options);
|
|
}
|
|
export class ResponseBodyError extends Error {
|
|
cause;
|
|
code;
|
|
error;
|
|
status;
|
|
error_description;
|
|
response;
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = this.constructor.name;
|
|
this.code = RESPONSE_BODY_ERROR;
|
|
this.cause = options.cause;
|
|
this.error = options.cause.error;
|
|
this.status = options.response.status;
|
|
this.error_description = options.cause.error_description;
|
|
Object.defineProperty(this, 'response', { enumerable: false, value: options.response });
|
|
Error.captureStackTrace?.(this, this.constructor);
|
|
}
|
|
}
|
|
export class AuthorizationResponseError extends Error {
|
|
cause;
|
|
code;
|
|
error;
|
|
error_description;
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = this.constructor.name;
|
|
this.code = AUTHORIZATION_RESPONSE_ERROR;
|
|
this.cause = options.cause;
|
|
this.error = options.cause.get('error');
|
|
this.error_description = options.cause.get('error_description') ?? undefined;
|
|
Error.captureStackTrace?.(this, this.constructor);
|
|
}
|
|
}
|
|
export class WWWAuthenticateChallengeError extends Error {
|
|
cause;
|
|
code;
|
|
response;
|
|
status;
|
|
constructor(message, options) {
|
|
super(message, options);
|
|
this.name = this.constructor.name;
|
|
this.code = WWW_AUTHENTICATE_CHALLENGE;
|
|
this.cause = options.cause;
|
|
this.status = options.response.status;
|
|
this.response = options.response;
|
|
Object.defineProperty(this, 'response', { enumerable: false });
|
|
Error.captureStackTrace?.(this, this.constructor);
|
|
}
|
|
}
|
|
const tokenMatch = "[a-zA-Z0-9!#$%&\\'\\*\\+\\-\\.\\^_`\\|~]+";
|
|
const token68Match = '[a-zA-Z0-9\\-\\._\\~\\+\\/]+={0,2}';
|
|
const quotedMatch = '"((?:[^"\\\\]|\\\\[\\s\\S])*)"';
|
|
const quotedParamMatcher = '(' + tokenMatch + ')\\s*=\\s*' + quotedMatch;
|
|
const paramMatcher = '(' + tokenMatch + ')\\s*=\\s*(' + tokenMatch + ')';
|
|
const schemeRE = new RegExp('^[,\\s]*(' + tokenMatch + ')');
|
|
const quotedParamRE = new RegExp('^[,\\s]*' + quotedParamMatcher + '[,\\s]*(.*)');
|
|
const unquotedParamRE = new RegExp('^[,\\s]*' + paramMatcher + '[,\\s]*(.*)');
|
|
const token68ParamRE = new RegExp('^(' + token68Match + ')(?:$|[,\\s])(.*)');
|
|
function parseWwwAuthenticateChallenges(response) {
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
const header = response.headers.get('www-authenticate');
|
|
if (header === null) {
|
|
return undefined;
|
|
}
|
|
const challenges = [];
|
|
let rest = header;
|
|
while (rest) {
|
|
let match = rest.match(schemeRE);
|
|
const scheme = match?.['1'].toLowerCase();
|
|
if (!scheme) {
|
|
return undefined;
|
|
}
|
|
const afterScheme = rest.substring(match[0].length);
|
|
if (afterScheme && !afterScheme.match(/^[\s,]/)) {
|
|
return undefined;
|
|
}
|
|
const spaceMatch = afterScheme.match(/^\s+(.*)$/);
|
|
const hasParameters = !!spaceMatch;
|
|
rest = spaceMatch ? spaceMatch[1] : undefined;
|
|
const parameters = {};
|
|
let token68;
|
|
if (hasParameters) {
|
|
while (rest) {
|
|
let key;
|
|
let value;
|
|
if ((match = rest.match(quotedParamRE))) {
|
|
;
|
|
[, key, value, rest] = match;
|
|
if (value.includes('\\')) {
|
|
try {
|
|
value = JSON.parse(`"${value}"`);
|
|
}
|
|
catch { }
|
|
}
|
|
parameters[key.toLowerCase()] = value;
|
|
continue;
|
|
}
|
|
if ((match = rest.match(unquotedParamRE))) {
|
|
;
|
|
[, key, value, rest] = match;
|
|
parameters[key.toLowerCase()] = value;
|
|
continue;
|
|
}
|
|
if ((match = rest.match(token68ParamRE))) {
|
|
if (Object.keys(parameters).length) {
|
|
break;
|
|
}
|
|
;
|
|
[, token68, rest] = match;
|
|
break;
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
else {
|
|
rest = afterScheme || undefined;
|
|
}
|
|
const challenge = { scheme, parameters };
|
|
if (token68) {
|
|
challenge.token68 = token68;
|
|
}
|
|
challenges.push(challenge);
|
|
}
|
|
if (!challenges.length) {
|
|
return undefined;
|
|
}
|
|
return challenges;
|
|
}
|
|
export async function processPushedAuthorizationResponse(as, client, response) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 201, 'Pushed Authorization Request Endpoint');
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.request_uri, '"response" body "request_uri" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
let expiresIn = typeof json.expires_in !== 'number' ? parseFloat(json.expires_in) : json.expires_in;
|
|
assertNumber(expiresIn, true, '"response" body "expires_in" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
json.expires_in = expiresIn;
|
|
return json;
|
|
}
|
|
async function parseOAuthResponseErrorBody(response) {
|
|
if (response.status > 399 && response.status < 500) {
|
|
assertReadableResponse(response);
|
|
assertApplicationJson(response);
|
|
try {
|
|
const json = await response.clone().json();
|
|
if (isJsonObject(json) && typeof json.error === 'string' && json.error.length) {
|
|
return json;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
return undefined;
|
|
}
|
|
async function checkOAuthBodyError(response, expected, label) {
|
|
if (response.status !== expected) {
|
|
checkAuthenticationChallenges(response);
|
|
let err;
|
|
if ((err = await parseOAuthResponseErrorBody(response))) {
|
|
await response.body?.cancel();
|
|
throw new ResponseBodyError('server responded with an error in the response body', {
|
|
cause: err,
|
|
response,
|
|
});
|
|
}
|
|
throw OPE(`"response" is not a conform ${label} response (unexpected HTTP status code)`, RESPONSE_IS_NOT_CONFORM, response);
|
|
}
|
|
}
|
|
function assertDPoP(option) {
|
|
if (!branded.has(option)) {
|
|
throw CodedTypeError('"options.DPoP" is not a valid DPoPHandle', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
async function resourceRequest(accessToken, method, url, headers, body, options) {
|
|
assertString(accessToken, '"accessToken"');
|
|
if (!(url instanceof URL)) {
|
|
throw CodedTypeError('"url" must be an instance of URL', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
checkProtocol(url, options?.[allowInsecureRequests] !== true);
|
|
headers = prepareHeaders(headers);
|
|
if (options?.DPoP) {
|
|
assertDPoP(options.DPoP);
|
|
await options.DPoP.addProof(url, headers, method.toUpperCase(), accessToken);
|
|
}
|
|
headers.set('authorization', `${headers.has('dpop') ? 'DPoP' : 'Bearer'} ${accessToken}`);
|
|
const response = await (options?.[customFetch] || fetch)(url.href, {
|
|
body,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
method,
|
|
redirect: 'manual',
|
|
signal: signal(url, options?.signal),
|
|
});
|
|
options?.DPoP?.cacheNonce(response, url);
|
|
return response;
|
|
}
|
|
export async function protectedResourceRequest(accessToken, method, url, headers, body, options) {
|
|
const response = await resourceRequest(accessToken, method, url, headers, body, options);
|
|
checkAuthenticationChallenges(response);
|
|
return response;
|
|
}
|
|
export async function userInfoRequest(as, client, accessToken, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
const url = resolveEndpoint(as, 'userinfo_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const headers = prepareHeaders(options?.headers);
|
|
if (client.userinfo_signed_response_alg) {
|
|
headers.set('accept', 'application/jwt');
|
|
}
|
|
else {
|
|
headers.set('accept', 'application/json');
|
|
headers.append('accept', 'application/jwt');
|
|
}
|
|
return resourceRequest(accessToken, 'GET', url, headers, null, {
|
|
...options,
|
|
[clockSkew]: getClockSkew(client),
|
|
});
|
|
}
|
|
let jwksMap;
|
|
function setJwksCache(as, jwks, uat, cache) {
|
|
jwksMap ||= new WeakMap();
|
|
jwksMap.set(as, {
|
|
jwks,
|
|
uat,
|
|
get age() {
|
|
return epochTime() - this.uat;
|
|
},
|
|
});
|
|
if (cache) {
|
|
Object.assign(cache, { jwks: structuredClone(jwks), uat });
|
|
}
|
|
}
|
|
function isFreshJwksCache(input) {
|
|
if (typeof input !== 'object' || input === null) {
|
|
return false;
|
|
}
|
|
if (!('uat' in input) || typeof input.uat !== 'number' || epochTime() - input.uat >= 300) {
|
|
return false;
|
|
}
|
|
if (!('jwks' in input) ||
|
|
!isJsonObject(input.jwks) ||
|
|
!Array.isArray(input.jwks.keys) ||
|
|
!Array.prototype.every.call(input.jwks.keys, isJsonObject)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
function clearJwksCache(as, cache) {
|
|
jwksMap?.delete(as);
|
|
delete cache?.jwks;
|
|
delete cache?.uat;
|
|
}
|
|
async function getPublicSigKeyFromIssuerJwksUri(as, options, header) {
|
|
const { alg, kid } = header;
|
|
checkSupportedJwsAlg(header);
|
|
if (!jwksMap?.has(as) && isFreshJwksCache(options?.[jwksCache])) {
|
|
setJwksCache(as, options?.[jwksCache].jwks, options?.[jwksCache].uat);
|
|
}
|
|
let jwks;
|
|
let age;
|
|
if (jwksMap?.has(as)) {
|
|
;
|
|
({ jwks, age } = jwksMap.get(as));
|
|
if (age >= 300) {
|
|
clearJwksCache(as, options?.[jwksCache]);
|
|
return getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
}
|
|
}
|
|
else {
|
|
jwks = await jwksRequest(as, options).then(processJwksResponse);
|
|
age = 0;
|
|
setJwksCache(as, jwks, epochTime(), options?.[jwksCache]);
|
|
}
|
|
let kty;
|
|
switch (alg.slice(0, 2)) {
|
|
case 'RS':
|
|
case 'PS':
|
|
kty = 'RSA';
|
|
break;
|
|
case 'ES':
|
|
kty = 'EC';
|
|
break;
|
|
case 'Ed':
|
|
kty = 'OKP';
|
|
break;
|
|
case 'ML':
|
|
kty = 'AKP';
|
|
break;
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported JWS algorithm', { cause: { alg } });
|
|
}
|
|
const candidates = jwks.keys.filter((jwk) => {
|
|
if (jwk.kty !== kty) {
|
|
return false;
|
|
}
|
|
if (kid !== undefined && kid !== jwk.kid) {
|
|
return false;
|
|
}
|
|
if (jwk.alg !== undefined && alg !== jwk.alg) {
|
|
return false;
|
|
}
|
|
if (jwk.use !== undefined && jwk.use !== 'sig') {
|
|
return false;
|
|
}
|
|
if (jwk.key_ops?.includes('verify') === false) {
|
|
return false;
|
|
}
|
|
switch (true) {
|
|
case alg === 'ES256' && jwk.crv !== 'P-256':
|
|
case alg === 'ES384' && jwk.crv !== 'P-384':
|
|
case alg === 'ES512' && jwk.crv !== 'P-521':
|
|
case alg === 'Ed25519' && jwk.crv !== 'Ed25519':
|
|
case alg === 'EdDSA' && jwk.crv !== 'Ed25519':
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
const { 0: jwk, length } = candidates;
|
|
if (!length) {
|
|
if (age >= 60) {
|
|
clearJwksCache(as, options?.[jwksCache]);
|
|
return getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
}
|
|
throw OPE('error when selecting a JWT verification key, no applicable keys found', KEY_SELECTION, { header, candidates, jwks_uri: new URL(as.jwks_uri) });
|
|
}
|
|
if (length !== 1) {
|
|
throw OPE('error when selecting a JWT verification key, multiple applicable keys found, a "kid" JWT Header Parameter is required', KEY_SELECTION, { header, candidates, jwks_uri: new URL(as.jwks_uri) });
|
|
}
|
|
return importJwk(alg, jwk);
|
|
}
|
|
export const skipSubjectCheck = Symbol();
|
|
export function getContentType(input) {
|
|
return input.headers.get('content-type')?.split(';')[0];
|
|
}
|
|
export async function processUserInfoResponse(as, client, expectedSubject, response, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
checkAuthenticationChallenges(response);
|
|
if (response.status !== 200) {
|
|
throw OPE('"response" is not a conform UserInfo Endpoint response (unexpected HTTP status code)', RESPONSE_IS_NOT_CONFORM, response);
|
|
}
|
|
assertReadableResponse(response);
|
|
let json;
|
|
if (getContentType(response) === 'application/jwt') {
|
|
const { claims, jwt } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.userinfo_signed_response_alg, as.userinfo_signing_alg_values_supported, undefined), getClockSkew(client), getClockTolerance(client), options?.[jweDecrypt])
|
|
.then(validateOptionalAudience.bind(undefined, client.client_id))
|
|
.then(validateOptionalIssuer.bind(undefined, as));
|
|
jwtRefs.set(response, jwt);
|
|
json = claims;
|
|
}
|
|
else {
|
|
if (client.userinfo_signed_response_alg) {
|
|
throw OPE('JWT UserInfo Response expected', JWT_USERINFO_EXPECTED, response);
|
|
}
|
|
json = await getResponseJsonBody(response);
|
|
}
|
|
assertString(json.sub, '"response" body "sub" property', INVALID_RESPONSE, { body: json });
|
|
switch (expectedSubject) {
|
|
case skipSubjectCheck:
|
|
break;
|
|
default:
|
|
assertString(expectedSubject, '"expectedSubject"');
|
|
if (json.sub !== expectedSubject) {
|
|
throw OPE('unexpected "response" body "sub" property value', JSON_ATTRIBUTE_COMPARISON, {
|
|
expected: expectedSubject,
|
|
body: json,
|
|
attribute: 'sub',
|
|
});
|
|
}
|
|
}
|
|
return json;
|
|
}
|
|
async function authenticatedRequest(as, client, clientAuthentication, url, body, headers, options) {
|
|
await clientAuthentication(as, client, body, headers);
|
|
headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
|
|
return (options?.[customFetch] || fetch)(url.href, {
|
|
body,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
method: 'POST',
|
|
redirect: 'manual',
|
|
signal: signal(url, options?.signal),
|
|
});
|
|
}
|
|
async function tokenEndpointRequest(as, client, clientAuthentication, grantType, parameters, options) {
|
|
const url = resolveEndpoint(as, 'token_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
parameters.set('grant_type', grantType);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
if (options?.DPoP !== undefined) {
|
|
assertDPoP(options.DPoP);
|
|
await options.DPoP.addProof(url, headers, 'POST');
|
|
}
|
|
const response = await authenticatedRequest(as, client, clientAuthentication, url, parameters, headers, options);
|
|
options?.DPoP?.cacheNonce(response, url);
|
|
return response;
|
|
}
|
|
export async function refreshTokenGrantRequest(as, client, clientAuthentication, refreshToken, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(refreshToken, '"refreshToken"');
|
|
const parameters = new URLSearchParams(options?.additionalParameters);
|
|
parameters.set('refresh_token', refreshToken);
|
|
return tokenEndpointRequest(as, client, clientAuthentication, 'refresh_token', parameters, options);
|
|
}
|
|
const idTokenClaims = new WeakMap();
|
|
const jwtRefs = new WeakMap();
|
|
export function getValidatedIdTokenClaims(ref) {
|
|
if (!ref.id_token) {
|
|
return undefined;
|
|
}
|
|
const claims = idTokenClaims.get(ref);
|
|
if (!claims) {
|
|
throw CodedTypeError('"ref" was already garbage collected or did not resolve from the proper sources', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
return claims;
|
|
}
|
|
export async function validateApplicationLevelSignature(as, ref, options) {
|
|
assertAs(as);
|
|
if (!jwtRefs.has(ref)) {
|
|
throw CodedTypeError('"ref" does not contain a processed JWT Response to verify the signature of', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
const { 0: protectedHeader, 1: payload, 2: encodedSignature } = jwtRefs.get(ref).split('.');
|
|
const header = JSON.parse(buf(b64u(protectedHeader)));
|
|
if (header.alg.startsWith('HS')) {
|
|
throw new UnsupportedOperationError('unsupported JWS algorithm', { cause: { alg: header.alg } });
|
|
}
|
|
let key;
|
|
key = await getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
await validateJwsSignature(protectedHeader, payload, key, b64u(encodedSignature));
|
|
}
|
|
async function processGenericAccessTokenResponse(as, client, response, additionalRequiredIdTokenClaims, decryptFn, recognizedTokenTypes) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 200, 'Token Endpoint');
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.access_token, '"response" body "access_token" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
assertString(json.token_type, '"response" body "token_type" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
json.token_type = json.token_type.toLowerCase();
|
|
if (json.expires_in !== undefined) {
|
|
let expiresIn = typeof json.expires_in !== 'number' ? parseFloat(json.expires_in) : json.expires_in;
|
|
assertNumber(expiresIn, true, '"response" body "expires_in" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
json.expires_in = expiresIn;
|
|
}
|
|
if (json.refresh_token !== undefined) {
|
|
assertString(json.refresh_token, '"response" body "refresh_token" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
if (json.scope !== undefined && typeof json.scope !== 'string') {
|
|
throw OPE('"response" body "scope" property must be a string', INVALID_RESPONSE, { body: json });
|
|
}
|
|
if (json.id_token !== undefined) {
|
|
assertString(json.id_token, '"response" body "id_token" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
const requiredClaims = ['aud', 'exp', 'iat', 'iss', 'sub'];
|
|
if (client.require_auth_time === true) {
|
|
requiredClaims.push('auth_time');
|
|
}
|
|
if (client.default_max_age !== undefined) {
|
|
assertNumber(client.default_max_age, true, '"client.default_max_age"');
|
|
requiredClaims.push('auth_time');
|
|
}
|
|
if (additionalRequiredIdTokenClaims?.length) {
|
|
requiredClaims.push(...additionalRequiredIdTokenClaims);
|
|
}
|
|
const { claims, jwt } = await validateJwt(json.id_token, checkSigningAlgorithm.bind(undefined, client.id_token_signed_response_alg, as.id_token_signing_alg_values_supported, 'RS256'), getClockSkew(client), getClockTolerance(client), decryptFn)
|
|
.then(validatePresence.bind(undefined, requiredClaims))
|
|
.then(validateIssuer.bind(undefined, as))
|
|
.then(validateAudience.bind(undefined, client.client_id));
|
|
if (Array.isArray(claims.aud) && claims.aud.length !== 1) {
|
|
if (claims.azp === undefined) {
|
|
throw OPE('ID Token "aud" (audience) claim includes additional untrusted audiences', JWT_CLAIM_COMPARISON, { claims, claim: 'aud' });
|
|
}
|
|
if (claims.azp !== client.client_id) {
|
|
throw OPE('unexpected ID Token "azp" (authorized party) claim value', JWT_CLAIM_COMPARISON, { expected: client.client_id, claims, claim: 'azp' });
|
|
}
|
|
}
|
|
if (claims.auth_time !== undefined) {
|
|
assertNumber(claims.auth_time, true, 'ID Token "auth_time" (authentication time)', INVALID_RESPONSE, { claims });
|
|
}
|
|
jwtRefs.set(response, jwt);
|
|
idTokenClaims.set(json, claims);
|
|
}
|
|
if (recognizedTokenTypes?.[json.token_type] !== undefined) {
|
|
recognizedTokenTypes[json.token_type](response, json);
|
|
}
|
|
else if (json.token_type !== 'dpop' && json.token_type !== 'bearer') {
|
|
throw new UnsupportedOperationError('unsupported `token_type` value', { cause: { body: json } });
|
|
}
|
|
return json;
|
|
}
|
|
function checkAuthenticationChallenges(response) {
|
|
let challenges;
|
|
if ((challenges = parseWwwAuthenticateChallenges(response))) {
|
|
throw new WWWAuthenticateChallengeError('server responded with a challenge in the WWW-Authenticate HTTP Header', { cause: challenges, response });
|
|
}
|
|
}
|
|
export async function processRefreshTokenResponse(as, client, response, options) {
|
|
return processGenericAccessTokenResponse(as, client, response, undefined, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
function validateOptionalAudience(expected, result) {
|
|
if (result.claims.aud !== undefined) {
|
|
return validateAudience(expected, result);
|
|
}
|
|
return result;
|
|
}
|
|
function validateAudience(expected, result) {
|
|
if (Array.isArray(result.claims.aud)) {
|
|
if (!result.claims.aud.includes(expected)) {
|
|
throw OPE('unexpected JWT "aud" (audience) claim value', JWT_CLAIM_COMPARISON, {
|
|
expected,
|
|
claims: result.claims,
|
|
claim: 'aud',
|
|
});
|
|
}
|
|
}
|
|
else if (result.claims.aud !== expected) {
|
|
throw OPE('unexpected JWT "aud" (audience) claim value', JWT_CLAIM_COMPARISON, {
|
|
expected,
|
|
claims: result.claims,
|
|
claim: 'aud',
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
function validateOptionalIssuer(as, result) {
|
|
if (result.claims.iss !== undefined) {
|
|
return validateIssuer(as, result);
|
|
}
|
|
return result;
|
|
}
|
|
function validateIssuer(as, result) {
|
|
const expected = as[_expectedIssuer]?.(result) ?? as.issuer;
|
|
if (result.claims.iss !== expected) {
|
|
throw OPE('unexpected JWT "iss" (issuer) claim value', JWT_CLAIM_COMPARISON, {
|
|
expected,
|
|
claims: result.claims,
|
|
claim: 'iss',
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
const branded = new WeakSet();
|
|
function brand(searchParams) {
|
|
branded.add(searchParams);
|
|
return searchParams;
|
|
}
|
|
export const nopkce = Symbol();
|
|
export async function authorizationCodeGrantRequest(as, client, clientAuthentication, callbackParameters, redirectUri, codeVerifier, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!branded.has(callbackParameters)) {
|
|
throw CodedTypeError('"callbackParameters" must be an instance of URLSearchParams obtained from "validateAuthResponse()", or "validateJwtAuthResponse()', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
assertString(redirectUri, '"redirectUri"');
|
|
const code = getURLSearchParameter(callbackParameters, 'code');
|
|
if (!code) {
|
|
throw OPE('no authorization code in "callbackParameters"', INVALID_RESPONSE);
|
|
}
|
|
const parameters = new URLSearchParams(options?.additionalParameters);
|
|
parameters.set('redirect_uri', redirectUri);
|
|
parameters.set('code', code);
|
|
if (codeVerifier !== nopkce) {
|
|
assertString(codeVerifier, '"codeVerifier"');
|
|
parameters.set('code_verifier', codeVerifier);
|
|
}
|
|
return tokenEndpointRequest(as, client, clientAuthentication, 'authorization_code', parameters, options);
|
|
}
|
|
const jwtClaimNames = {
|
|
aud: 'audience',
|
|
c_hash: 'code hash',
|
|
client_id: 'client id',
|
|
exp: 'expiration time',
|
|
iat: 'issued at',
|
|
iss: 'issuer',
|
|
jti: 'jwt id',
|
|
nonce: 'nonce',
|
|
s_hash: 'state hash',
|
|
sub: 'subject',
|
|
ath: 'access token hash',
|
|
htm: 'http method',
|
|
htu: 'http uri',
|
|
cnf: 'confirmation',
|
|
auth_time: 'authentication time',
|
|
};
|
|
function validatePresence(required, result) {
|
|
for (const claim of required) {
|
|
if (result.claims[claim] === undefined) {
|
|
throw OPE(`JWT "${claim}" (${jwtClaimNames[claim]}) claim missing`, INVALID_RESPONSE, {
|
|
claims: result.claims,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
export const expectNoNonce = Symbol();
|
|
export const skipAuthTimeCheck = Symbol();
|
|
export async function processAuthorizationCodeResponse(as, client, response, options) {
|
|
if (typeof options?.expectedNonce === 'string' ||
|
|
typeof options?.maxAge === 'number' ||
|
|
options?.requireIdToken) {
|
|
return processAuthorizationCodeOpenIDResponse(as, client, response, options.expectedNonce, options.maxAge, options[jweDecrypt], options.recognizedTokenTypes);
|
|
}
|
|
return processAuthorizationCodeOAuth2Response(as, client, response, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
async function processAuthorizationCodeOpenIDResponse(as, client, response, expectedNonce, maxAge, decryptFn, recognizedTokenTypes) {
|
|
const additionalRequiredClaims = [];
|
|
switch (expectedNonce) {
|
|
case undefined:
|
|
expectedNonce = expectNoNonce;
|
|
break;
|
|
case expectNoNonce:
|
|
break;
|
|
default:
|
|
assertString(expectedNonce, '"expectedNonce" argument');
|
|
additionalRequiredClaims.push('nonce');
|
|
}
|
|
maxAge ??= client.default_max_age;
|
|
switch (maxAge) {
|
|
case undefined:
|
|
maxAge = skipAuthTimeCheck;
|
|
break;
|
|
case skipAuthTimeCheck:
|
|
break;
|
|
default:
|
|
assertNumber(maxAge, true, '"maxAge" argument');
|
|
additionalRequiredClaims.push('auth_time');
|
|
}
|
|
const result = await processGenericAccessTokenResponse(as, client, response, additionalRequiredClaims, decryptFn, recognizedTokenTypes);
|
|
assertString(result.id_token, '"response" body "id_token" property', INVALID_RESPONSE, {
|
|
body: result,
|
|
});
|
|
const claims = getValidatedIdTokenClaims(result);
|
|
if (maxAge !== skipAuthTimeCheck) {
|
|
const now = epochTime() + getClockSkew(client);
|
|
const tolerance = getClockTolerance(client);
|
|
if (claims.auth_time + maxAge < now - tolerance) {
|
|
throw OPE('too much time has elapsed since the last End-User authentication', JWT_TIMESTAMP_CHECK, { claims, now, tolerance, claim: 'auth_time' });
|
|
}
|
|
}
|
|
if (expectedNonce === expectNoNonce) {
|
|
if (claims.nonce !== undefined) {
|
|
throw OPE('unexpected ID Token "nonce" claim value', JWT_CLAIM_COMPARISON, {
|
|
expected: undefined,
|
|
claims,
|
|
claim: 'nonce',
|
|
});
|
|
}
|
|
}
|
|
else if (claims.nonce !== expectedNonce) {
|
|
throw OPE('unexpected ID Token "nonce" claim value', JWT_CLAIM_COMPARISON, {
|
|
expected: expectedNonce,
|
|
claims,
|
|
claim: 'nonce',
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
async function processAuthorizationCodeOAuth2Response(as, client, response, decryptFn, recognizedTokenTypes) {
|
|
const result = await processGenericAccessTokenResponse(as, client, response, undefined, decryptFn, recognizedTokenTypes);
|
|
const claims = getValidatedIdTokenClaims(result);
|
|
if (claims) {
|
|
if (client.default_max_age !== undefined) {
|
|
assertNumber(client.default_max_age, true, '"client.default_max_age"');
|
|
const now = epochTime() + getClockSkew(client);
|
|
const tolerance = getClockTolerance(client);
|
|
if (claims.auth_time + client.default_max_age < now - tolerance) {
|
|
throw OPE('too much time has elapsed since the last End-User authentication', JWT_TIMESTAMP_CHECK, { claims, now, tolerance, claim: 'auth_time' });
|
|
}
|
|
}
|
|
if (claims.nonce !== undefined) {
|
|
throw OPE('unexpected ID Token "nonce" claim value', JWT_CLAIM_COMPARISON, {
|
|
expected: undefined,
|
|
claims,
|
|
claim: 'nonce',
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
export const WWW_AUTHENTICATE_CHALLENGE = 'OAUTH_WWW_AUTHENTICATE_CHALLENGE';
|
|
export const RESPONSE_BODY_ERROR = 'OAUTH_RESPONSE_BODY_ERROR';
|
|
export const UNSUPPORTED_OPERATION = 'OAUTH_UNSUPPORTED_OPERATION';
|
|
export const AUTHORIZATION_RESPONSE_ERROR = 'OAUTH_AUTHORIZATION_RESPONSE_ERROR';
|
|
export const JWT_USERINFO_EXPECTED = 'OAUTH_JWT_USERINFO_EXPECTED';
|
|
export const PARSE_ERROR = 'OAUTH_PARSE_ERROR';
|
|
export const INVALID_RESPONSE = 'OAUTH_INVALID_RESPONSE';
|
|
export const INVALID_REQUEST = 'OAUTH_INVALID_REQUEST';
|
|
export const RESPONSE_IS_NOT_JSON = 'OAUTH_RESPONSE_IS_NOT_JSON';
|
|
export const RESPONSE_IS_NOT_CONFORM = 'OAUTH_RESPONSE_IS_NOT_CONFORM';
|
|
export const HTTP_REQUEST_FORBIDDEN = 'OAUTH_HTTP_REQUEST_FORBIDDEN';
|
|
export const REQUEST_PROTOCOL_FORBIDDEN = 'OAUTH_REQUEST_PROTOCOL_FORBIDDEN';
|
|
export const JWT_TIMESTAMP_CHECK = 'OAUTH_JWT_TIMESTAMP_CHECK_FAILED';
|
|
export const JWT_CLAIM_COMPARISON = 'OAUTH_JWT_CLAIM_COMPARISON_FAILED';
|
|
export const JSON_ATTRIBUTE_COMPARISON = 'OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED';
|
|
export const KEY_SELECTION = 'OAUTH_KEY_SELECTION_FAILED';
|
|
export const MISSING_SERVER_METADATA = 'OAUTH_MISSING_SERVER_METADATA';
|
|
export const INVALID_SERVER_METADATA = 'OAUTH_INVALID_SERVER_METADATA';
|
|
function checkJwtType(expected, result) {
|
|
if (typeof result.header.typ !== 'string' || normalizeTyp(result.header.typ) !== expected) {
|
|
throw OPE('unexpected JWT "typ" header parameter value', INVALID_RESPONSE, {
|
|
header: result.header,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
export async function clientCredentialsGrantRequest(as, client, clientAuthentication, parameters, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
return tokenEndpointRequest(as, client, clientAuthentication, 'client_credentials', new URLSearchParams(parameters), options);
|
|
}
|
|
export async function genericTokenEndpointRequest(as, client, clientAuthentication, grantType, parameters, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(grantType, '"grantType"');
|
|
return tokenEndpointRequest(as, client, clientAuthentication, grantType, new URLSearchParams(parameters), options);
|
|
}
|
|
export async function processGenericTokenEndpointResponse(as, client, response, options) {
|
|
return processGenericAccessTokenResponse(as, client, response, undefined, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
export async function processClientCredentialsResponse(as, client, response, options) {
|
|
return processGenericAccessTokenResponse(as, client, response, undefined, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
export async function revocationRequest(as, client, clientAuthentication, token, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(token, '"token"');
|
|
const url = resolveEndpoint(as, 'revocation_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const body = new URLSearchParams(options?.additionalParameters);
|
|
body.set('token', token);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.delete('accept');
|
|
return authenticatedRequest(as, client, clientAuthentication, url, body, headers, options);
|
|
}
|
|
export async function processRevocationResponse(response) {
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 200, 'Revocation Endpoint');
|
|
return undefined;
|
|
}
|
|
function assertReadableResponse(response) {
|
|
if (response.bodyUsed) {
|
|
throw CodedTypeError('"response" body has been used already', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
}
|
|
export async function introspectionRequest(as, client, clientAuthentication, token, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(token, '"token"');
|
|
const url = resolveEndpoint(as, 'introspection_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const body = new URLSearchParams(options?.additionalParameters);
|
|
body.set('token', token);
|
|
const headers = prepareHeaders(options?.headers);
|
|
if (options?.requestJwtResponse ?? client.introspection_signed_response_alg) {
|
|
headers.set('accept', 'application/token-introspection+jwt');
|
|
}
|
|
else {
|
|
headers.set('accept', 'application/json');
|
|
}
|
|
return authenticatedRequest(as, client, clientAuthentication, url, body, headers, options);
|
|
}
|
|
export async function processIntrospectionResponse(as, client, response, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 200, 'Introspection Endpoint');
|
|
let json;
|
|
if (getContentType(response) === 'application/token-introspection+jwt') {
|
|
assertReadableResponse(response);
|
|
const { claims, jwt } = await validateJwt(await response.text(), checkSigningAlgorithm.bind(undefined, client.introspection_signed_response_alg, as.introspection_signing_alg_values_supported, 'RS256'), getClockSkew(client), getClockTolerance(client), options?.[jweDecrypt])
|
|
.then(checkJwtType.bind(undefined, 'token-introspection+jwt'))
|
|
.then(validatePresence.bind(undefined, ['aud', 'iat', 'iss']))
|
|
.then(validateIssuer.bind(undefined, as))
|
|
.then(validateAudience.bind(undefined, client.client_id));
|
|
jwtRefs.set(response, jwt);
|
|
if (!isJsonObject(claims.token_introspection)) {
|
|
throw OPE('JWT "token_introspection" claim must be a JSON object', INVALID_RESPONSE, {
|
|
claims,
|
|
});
|
|
}
|
|
json = claims.token_introspection;
|
|
}
|
|
else {
|
|
assertReadableResponse(response);
|
|
json = await getResponseJsonBody(response);
|
|
}
|
|
if (typeof json.active !== 'boolean') {
|
|
throw OPE('"response" body "active" property must be a boolean', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
return json;
|
|
}
|
|
async function jwksRequest(as, options) {
|
|
assertAs(as);
|
|
const url = resolveEndpoint(as, 'jwks_uri', false, options?.[allowInsecureRequests] !== true);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
headers.append('accept', 'application/jwk-set+json');
|
|
return (options?.[customFetch] || fetch)(url.href, {
|
|
body: undefined,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
signal: signal(url, options?.signal),
|
|
});
|
|
}
|
|
async function processJwksResponse(response) {
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (response.status !== 200) {
|
|
throw OPE('"response" is not a conform JSON Web Key Set response (unexpected HTTP status code)', RESPONSE_IS_NOT_CONFORM, response);
|
|
}
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response, (response) => assertContentTypes(response, 'application/json', 'application/jwk-set+json'));
|
|
if (!Array.isArray(json.keys)) {
|
|
throw OPE('"response" body "keys" property must be an array', INVALID_RESPONSE, { body: json });
|
|
}
|
|
if (!Array.prototype.every.call(json.keys, isJsonObject)) {
|
|
throw OPE('"response" body "keys" property members must be JWK formatted objects', INVALID_RESPONSE, { body: json });
|
|
}
|
|
return json;
|
|
}
|
|
function supported(alg) {
|
|
switch (alg) {
|
|
case 'PS256':
|
|
case 'ES256':
|
|
case 'RS256':
|
|
case 'PS384':
|
|
case 'ES384':
|
|
case 'RS384':
|
|
case 'PS512':
|
|
case 'ES512':
|
|
case 'RS512':
|
|
case 'Ed25519':
|
|
case 'EdDSA':
|
|
case 'ML-DSA-44':
|
|
case 'ML-DSA-65':
|
|
case 'ML-DSA-87':
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
function checkSupportedJwsAlg(header) {
|
|
if (!supported(header.alg)) {
|
|
throw new UnsupportedOperationError('unsupported JWS "alg" identifier', {
|
|
cause: { alg: header.alg },
|
|
});
|
|
}
|
|
}
|
|
function checkRsaKeyAlgorithm(key) {
|
|
const { algorithm } = key;
|
|
if (typeof algorithm.modulusLength !== 'number' || algorithm.modulusLength < 2048) {
|
|
throw new UnsupportedOperationError(`unsupported ${algorithm.name} modulusLength`, {
|
|
cause: key,
|
|
});
|
|
}
|
|
}
|
|
function ecdsaHashName(key) {
|
|
const { algorithm } = key;
|
|
switch (algorithm.namedCurve) {
|
|
case 'P-256':
|
|
return 'SHA-256';
|
|
case 'P-384':
|
|
return 'SHA-384';
|
|
case 'P-521':
|
|
return 'SHA-512';
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported ECDSA namedCurve', { cause: key });
|
|
}
|
|
}
|
|
function keyToSubtle(key) {
|
|
switch (key.algorithm.name) {
|
|
case 'ECDSA':
|
|
return {
|
|
name: key.algorithm.name,
|
|
hash: ecdsaHashName(key),
|
|
};
|
|
case 'RSA-PSS': {
|
|
checkRsaKeyAlgorithm(key);
|
|
switch (key.algorithm.hash.name) {
|
|
case 'SHA-256':
|
|
case 'SHA-384':
|
|
case 'SHA-512':
|
|
return {
|
|
name: key.algorithm.name,
|
|
saltLength: parseInt(key.algorithm.hash.name.slice(-3), 10) >> 3,
|
|
};
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported RSA-PSS hash name', { cause: key });
|
|
}
|
|
}
|
|
case 'RSASSA-PKCS1-v1_5':
|
|
checkRsaKeyAlgorithm(key);
|
|
return key.algorithm.name;
|
|
case 'ML-DSA-44':
|
|
case 'ML-DSA-65':
|
|
case 'ML-DSA-87':
|
|
case 'Ed25519':
|
|
return key.algorithm.name;
|
|
}
|
|
throw new UnsupportedOperationError('unsupported CryptoKey algorithm name', { cause: key });
|
|
}
|
|
async function validateJwsSignature(protectedHeader, payload, key, signature) {
|
|
const data = buf(`${protectedHeader}.${payload}`);
|
|
const algorithm = keyToSubtle(key);
|
|
const verified = await crypto.subtle.verify(algorithm, key, signature, data);
|
|
if (!verified) {
|
|
throw OPE('JWT signature verification failed', INVALID_RESPONSE, {
|
|
key,
|
|
data,
|
|
signature,
|
|
algorithm,
|
|
});
|
|
}
|
|
}
|
|
async function validateJwt(jws, checkAlg, clockSkew, clockTolerance, decryptJwt) {
|
|
let { 0: protectedHeader, 1: payload, length } = jws.split('.');
|
|
if (length === 5) {
|
|
if (decryptJwt !== undefined) {
|
|
jws = await decryptJwt(jws);
|
|
({ 0: protectedHeader, 1: payload, length } = jws.split('.'));
|
|
}
|
|
else {
|
|
throw new UnsupportedOperationError('JWE decryption is not configured', { cause: jws });
|
|
}
|
|
}
|
|
if (length !== 3) {
|
|
throw OPE('Invalid JWT', INVALID_RESPONSE, jws);
|
|
}
|
|
let header;
|
|
try {
|
|
header = JSON.parse(buf(b64u(protectedHeader)));
|
|
}
|
|
catch (cause) {
|
|
throw OPE('failed to parse JWT Header body as base64url encoded JSON', PARSE_ERROR, cause);
|
|
}
|
|
if (!isJsonObject(header)) {
|
|
throw OPE('JWT Header must be a top level object', INVALID_RESPONSE, jws);
|
|
}
|
|
checkAlg(header);
|
|
if (header.crit !== undefined) {
|
|
throw new UnsupportedOperationError('no JWT "crit" header parameter extensions are supported', {
|
|
cause: { header },
|
|
});
|
|
}
|
|
let claims;
|
|
try {
|
|
claims = JSON.parse(buf(b64u(payload)));
|
|
}
|
|
catch (cause) {
|
|
throw OPE('failed to parse JWT Payload body as base64url encoded JSON', PARSE_ERROR, cause);
|
|
}
|
|
if (!isJsonObject(claims)) {
|
|
throw OPE('JWT Payload must be a top level object', INVALID_RESPONSE, jws);
|
|
}
|
|
const now = epochTime() + clockSkew;
|
|
if (claims.exp !== undefined) {
|
|
if (typeof claims.exp !== 'number') {
|
|
throw OPE('unexpected JWT "exp" (expiration time) claim type', INVALID_RESPONSE, { claims });
|
|
}
|
|
if (claims.exp <= now - clockTolerance) {
|
|
throw OPE('unexpected JWT "exp" (expiration time) claim value, expiration is past current timestamp', JWT_TIMESTAMP_CHECK, { claims, now, tolerance: clockTolerance, claim: 'exp' });
|
|
}
|
|
}
|
|
if (claims.iat !== undefined) {
|
|
if (typeof claims.iat !== 'number') {
|
|
throw OPE('unexpected JWT "iat" (issued at) claim type', INVALID_RESPONSE, { claims });
|
|
}
|
|
}
|
|
if (claims.iss !== undefined) {
|
|
if (typeof claims.iss !== 'string') {
|
|
throw OPE('unexpected JWT "iss" (issuer) claim type', INVALID_RESPONSE, { claims });
|
|
}
|
|
}
|
|
if (claims.nbf !== undefined) {
|
|
if (typeof claims.nbf !== 'number') {
|
|
throw OPE('unexpected JWT "nbf" (not before) claim type', INVALID_RESPONSE, { claims });
|
|
}
|
|
if (claims.nbf > now + clockTolerance) {
|
|
throw OPE('unexpected JWT "nbf" (not before) claim value', JWT_TIMESTAMP_CHECK, {
|
|
claims,
|
|
now,
|
|
tolerance: clockTolerance,
|
|
claim: 'nbf',
|
|
});
|
|
}
|
|
}
|
|
if (claims.aud !== undefined) {
|
|
if (typeof claims.aud !== 'string' && !Array.isArray(claims.aud)) {
|
|
throw OPE('unexpected JWT "aud" (audience) claim type', INVALID_RESPONSE, { claims });
|
|
}
|
|
}
|
|
return { header, claims, jwt: jws };
|
|
}
|
|
export async function validateJwtAuthResponse(as, client, parameters, expectedState, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (parameters instanceof URL) {
|
|
parameters = parameters.searchParams;
|
|
}
|
|
if (!(parameters instanceof URLSearchParams)) {
|
|
throw CodedTypeError('"parameters" must be an instance of URLSearchParams, or URL', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
const response = getURLSearchParameter(parameters, 'response');
|
|
if (!response) {
|
|
throw OPE('"parameters" does not contain a JARM response', INVALID_RESPONSE);
|
|
}
|
|
const { claims, header, jwt } = await validateJwt(response, checkSigningAlgorithm.bind(undefined, client.authorization_signed_response_alg, as.authorization_signing_alg_values_supported, 'RS256'), getClockSkew(client), getClockTolerance(client), options?.[jweDecrypt])
|
|
.then(validatePresence.bind(undefined, ['aud', 'exp', 'iss']))
|
|
.then(validateIssuer.bind(undefined, as))
|
|
.then(validateAudience.bind(undefined, client.client_id));
|
|
const { 0: protectedHeader, 1: payload, 2: encodedSignature } = jwt.split('.');
|
|
const signature = b64u(encodedSignature);
|
|
const key = await getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
await validateJwsSignature(protectedHeader, payload, key, signature);
|
|
const result = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(claims)) {
|
|
if (typeof value === 'string' && key !== 'aud') {
|
|
result.set(key, value);
|
|
}
|
|
}
|
|
return validateAuthResponse(as, client, result, expectedState);
|
|
}
|
|
async function idTokenHash(data, header, claimName) {
|
|
let algorithm;
|
|
switch (header.alg) {
|
|
case 'RS256':
|
|
case 'PS256':
|
|
case 'ES256':
|
|
algorithm = 'SHA-256';
|
|
break;
|
|
case 'RS384':
|
|
case 'PS384':
|
|
case 'ES384':
|
|
algorithm = 'SHA-384';
|
|
break;
|
|
case 'RS512':
|
|
case 'PS512':
|
|
case 'ES512':
|
|
case 'Ed25519':
|
|
case 'EdDSA':
|
|
algorithm = 'SHA-512';
|
|
break;
|
|
case 'ML-DSA-44':
|
|
case 'ML-DSA-65':
|
|
case 'ML-DSA-87':
|
|
algorithm = { name: 'cSHAKE256', length: 512 };
|
|
break;
|
|
default:
|
|
throw new UnsupportedOperationError(`unsupported JWS algorithm for ${claimName} calculation`, { cause: { alg: header.alg } });
|
|
}
|
|
const digest = await crypto.subtle.digest(algorithm, buf(data));
|
|
return b64u(digest.slice(0, digest.byteLength / 2));
|
|
}
|
|
async function idTokenHashMatches(data, actual, header, claimName) {
|
|
const expected = await idTokenHash(data, header, claimName);
|
|
return actual === expected;
|
|
}
|
|
export async function validateDetachedSignatureResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options) {
|
|
return validateHybridResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options, true);
|
|
}
|
|
export async function validateCodeIdTokenResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options) {
|
|
return validateHybridResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options, false);
|
|
}
|
|
async function consumeStream(request) {
|
|
if (request.bodyUsed) {
|
|
throw CodedTypeError('form_post Request instances must contain a readable body', ERR_INVALID_ARG_VALUE, { cause: request });
|
|
}
|
|
return request.text();
|
|
}
|
|
export async function formPostResponse(request) {
|
|
if (request.method !== 'POST') {
|
|
throw CodedTypeError('form_post responses are expected to use the POST method', ERR_INVALID_ARG_VALUE, { cause: request });
|
|
}
|
|
if (getContentType(request) !== 'application/x-www-form-urlencoded') {
|
|
throw CodedTypeError('form_post responses are expected to use the application/x-www-form-urlencoded content-type', ERR_INVALID_ARG_VALUE, { cause: request });
|
|
}
|
|
return consumeStream(request);
|
|
}
|
|
async function validateHybridResponse(as, client, parameters, expectedNonce, expectedState, maxAge, options, fapi) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (parameters instanceof URL) {
|
|
if (!parameters.hash.length) {
|
|
throw CodedTypeError('"parameters" as an instance of URL must contain a hash (fragment) with the Authorization Response parameters', ERR_INVALID_ARG_VALUE);
|
|
}
|
|
parameters = new URLSearchParams(parameters.hash.slice(1));
|
|
}
|
|
else if (looseInstanceOf(parameters, Request)) {
|
|
parameters = new URLSearchParams(await formPostResponse(parameters));
|
|
}
|
|
else if (parameters instanceof URLSearchParams) {
|
|
parameters = new URLSearchParams(parameters);
|
|
}
|
|
else {
|
|
throw CodedTypeError('"parameters" must be an instance of URLSearchParams, URL, or Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
const id_token = getURLSearchParameter(parameters, 'id_token');
|
|
parameters.delete('id_token');
|
|
switch (expectedState) {
|
|
case undefined:
|
|
case expectNoState:
|
|
break;
|
|
default:
|
|
assertString(expectedState, '"expectedState" argument');
|
|
}
|
|
const result = validateAuthResponse({
|
|
...as,
|
|
authorization_response_iss_parameter_supported: false,
|
|
}, client, parameters, expectedState);
|
|
if (!id_token) {
|
|
throw OPE('"parameters" does not contain an ID Token', INVALID_RESPONSE);
|
|
}
|
|
const code = getURLSearchParameter(parameters, 'code');
|
|
if (!code) {
|
|
throw OPE('"parameters" does not contain an Authorization Code', INVALID_RESPONSE);
|
|
}
|
|
const requiredClaims = [
|
|
'aud',
|
|
'exp',
|
|
'iat',
|
|
'iss',
|
|
'sub',
|
|
'nonce',
|
|
'c_hash',
|
|
];
|
|
const state = parameters.get('state');
|
|
if (fapi && (typeof expectedState === 'string' || state !== null)) {
|
|
requiredClaims.push('s_hash');
|
|
}
|
|
if (maxAge !== undefined) {
|
|
assertNumber(maxAge, true, '"maxAge" argument');
|
|
}
|
|
else if (client.default_max_age !== undefined) {
|
|
assertNumber(client.default_max_age, true, '"client.default_max_age"');
|
|
}
|
|
maxAge ??= client.default_max_age ?? skipAuthTimeCheck;
|
|
if (client.require_auth_time || maxAge !== skipAuthTimeCheck) {
|
|
requiredClaims.push('auth_time');
|
|
}
|
|
const { claims, header, jwt } = await validateJwt(id_token, checkSigningAlgorithm.bind(undefined, client.id_token_signed_response_alg, as.id_token_signing_alg_values_supported, 'RS256'), getClockSkew(client), getClockTolerance(client), options?.[jweDecrypt])
|
|
.then(validatePresence.bind(undefined, requiredClaims))
|
|
.then(validateIssuer.bind(undefined, as))
|
|
.then(validateAudience.bind(undefined, client.client_id));
|
|
const clockSkew = getClockSkew(client);
|
|
const now = epochTime() + clockSkew;
|
|
if (claims.iat < now - 3600) {
|
|
throw OPE('unexpected JWT "iat" (issued at) claim value, it is too far in the past', JWT_TIMESTAMP_CHECK, { now, claims, claim: 'iat' });
|
|
}
|
|
assertString(claims.c_hash, 'ID Token "c_hash" (code hash) claim value', INVALID_RESPONSE, {
|
|
claims,
|
|
});
|
|
if (claims.auth_time !== undefined) {
|
|
assertNumber(claims.auth_time, true, 'ID Token "auth_time" (authentication time)', INVALID_RESPONSE, { claims });
|
|
}
|
|
if (maxAge !== skipAuthTimeCheck) {
|
|
const now = epochTime() + getClockSkew(client);
|
|
const tolerance = getClockTolerance(client);
|
|
if (claims.auth_time + maxAge < now - tolerance) {
|
|
throw OPE('too much time has elapsed since the last End-User authentication', JWT_TIMESTAMP_CHECK, { claims, now, tolerance, claim: 'auth_time' });
|
|
}
|
|
}
|
|
assertString(expectedNonce, '"expectedNonce" argument');
|
|
if (claims.nonce !== expectedNonce) {
|
|
throw OPE('unexpected ID Token "nonce" claim value', JWT_CLAIM_COMPARISON, {
|
|
expected: expectedNonce,
|
|
claims,
|
|
claim: 'nonce',
|
|
});
|
|
}
|
|
if (Array.isArray(claims.aud) && claims.aud.length !== 1) {
|
|
if (claims.azp === undefined) {
|
|
throw OPE('ID Token "aud" (audience) claim includes additional untrusted audiences', JWT_CLAIM_COMPARISON, { claims, claim: 'aud' });
|
|
}
|
|
if (claims.azp !== client.client_id) {
|
|
throw OPE('unexpected ID Token "azp" (authorized party) claim value', JWT_CLAIM_COMPARISON, {
|
|
expected: client.client_id,
|
|
claims,
|
|
claim: 'azp',
|
|
});
|
|
}
|
|
}
|
|
const { 0: protectedHeader, 1: payload, 2: encodedSignature } = jwt.split('.');
|
|
const signature = b64u(encodedSignature);
|
|
const key = await getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
await validateJwsSignature(protectedHeader, payload, key, signature);
|
|
if ((await idTokenHashMatches(code, claims.c_hash, header, 'c_hash')) !== true) {
|
|
throw OPE('invalid ID Token "c_hash" (code hash) claim value', JWT_CLAIM_COMPARISON, {
|
|
code,
|
|
alg: header.alg,
|
|
claim: 'c_hash',
|
|
claims,
|
|
});
|
|
}
|
|
if ((fapi && state !== null) || claims.s_hash !== undefined) {
|
|
assertString(claims.s_hash, 'ID Token "s_hash" (state hash) claim value', INVALID_RESPONSE, {
|
|
claims,
|
|
});
|
|
assertString(state, '"state" response parameter', INVALID_RESPONSE, { parameters });
|
|
if ((await idTokenHashMatches(state, claims.s_hash, header, 's_hash')) !== true) {
|
|
throw OPE('invalid ID Token "s_hash" (state hash) claim value', JWT_CLAIM_COMPARISON, {
|
|
state,
|
|
alg: header.alg,
|
|
claim: 's_hash',
|
|
claims,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
function checkSigningAlgorithm(client, issuer, fallback, header) {
|
|
if (client !== undefined) {
|
|
if (typeof client === 'string' ? header.alg !== client : !client.includes(header.alg)) {
|
|
throw OPE('unexpected JWT "alg" header parameter', INVALID_RESPONSE, {
|
|
header,
|
|
expected: client,
|
|
reason: 'client configuration',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(issuer)) {
|
|
if (!issuer.includes(header.alg)) {
|
|
throw OPE('unexpected JWT "alg" header parameter', INVALID_RESPONSE, {
|
|
header,
|
|
expected: issuer,
|
|
reason: 'authorization server metadata',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (fallback !== undefined) {
|
|
if (typeof fallback === 'string'
|
|
? header.alg !== fallback
|
|
: typeof fallback === 'function'
|
|
? !fallback(header.alg)
|
|
: !fallback.includes(header.alg)) {
|
|
throw OPE('unexpected JWT "alg" header parameter', INVALID_RESPONSE, {
|
|
header,
|
|
expected: fallback,
|
|
reason: 'default value',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
throw OPE('missing client or server configuration to verify used JWT "alg" header parameter', undefined, { client, issuer, fallback });
|
|
}
|
|
function getURLSearchParameter(parameters, name) {
|
|
const { 0: value, length } = parameters.getAll(name);
|
|
if (length > 1) {
|
|
throw OPE(`"${name}" parameter must be provided only once`, INVALID_RESPONSE);
|
|
}
|
|
return value;
|
|
}
|
|
export const skipStateCheck = Symbol();
|
|
export const expectNoState = Symbol();
|
|
export function validateAuthResponse(as, client, parameters, expectedState) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (parameters instanceof URL) {
|
|
parameters = parameters.searchParams;
|
|
}
|
|
if (!(parameters instanceof URLSearchParams)) {
|
|
throw CodedTypeError('"parameters" must be an instance of URLSearchParams, or URL', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (getURLSearchParameter(parameters, 'response')) {
|
|
throw OPE('"parameters" contains a JARM response, use validateJwtAuthResponse() instead of validateAuthResponse()', INVALID_RESPONSE, { parameters });
|
|
}
|
|
const iss = getURLSearchParameter(parameters, 'iss');
|
|
const state = getURLSearchParameter(parameters, 'state');
|
|
if (!iss && as.authorization_response_iss_parameter_supported) {
|
|
throw OPE('response parameter "iss" (issuer) missing', INVALID_RESPONSE, { parameters });
|
|
}
|
|
if (iss && iss !== as.issuer) {
|
|
throw OPE('unexpected "iss" (issuer) response parameter value', INVALID_RESPONSE, {
|
|
expected: as.issuer,
|
|
parameters,
|
|
});
|
|
}
|
|
switch (expectedState) {
|
|
case undefined:
|
|
case expectNoState:
|
|
if (state !== undefined) {
|
|
throw OPE('unexpected "state" response parameter encountered', INVALID_RESPONSE, {
|
|
expected: undefined,
|
|
parameters,
|
|
});
|
|
}
|
|
break;
|
|
case skipStateCheck:
|
|
break;
|
|
default:
|
|
assertString(expectedState, '"expectedState" argument');
|
|
if (state !== expectedState) {
|
|
throw OPE(state === undefined
|
|
? 'response parameter "state" missing'
|
|
: 'unexpected "state" response parameter value', INVALID_RESPONSE, { expected: expectedState, parameters });
|
|
}
|
|
}
|
|
const error = getURLSearchParameter(parameters, 'error');
|
|
if (error) {
|
|
throw new AuthorizationResponseError('authorization response from the server is an error', {
|
|
cause: parameters,
|
|
});
|
|
}
|
|
const id_token = getURLSearchParameter(parameters, 'id_token');
|
|
const token = getURLSearchParameter(parameters, 'token');
|
|
if (id_token !== undefined || token !== undefined) {
|
|
throw new UnsupportedOperationError('implicit and hybrid flows are not supported');
|
|
}
|
|
return brand(new URLSearchParams(parameters));
|
|
}
|
|
function algToSubtle(alg) {
|
|
switch (alg) {
|
|
case 'PS256':
|
|
case 'PS384':
|
|
case 'PS512':
|
|
return { name: 'RSA-PSS', hash: `SHA-${alg.slice(-3)}` };
|
|
case 'RS256':
|
|
case 'RS384':
|
|
case 'RS512':
|
|
return { name: 'RSASSA-PKCS1-v1_5', hash: `SHA-${alg.slice(-3)}` };
|
|
case 'ES256':
|
|
case 'ES384':
|
|
return { name: 'ECDSA', namedCurve: `P-${alg.slice(-3)}` };
|
|
case 'ES512':
|
|
return { name: 'ECDSA', namedCurve: 'P-521' };
|
|
case 'EdDSA':
|
|
return 'Ed25519';
|
|
case 'Ed25519':
|
|
case 'ML-DSA-44':
|
|
case 'ML-DSA-65':
|
|
case 'ML-DSA-87':
|
|
return alg;
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported JWS algorithm', { cause: { alg } });
|
|
}
|
|
}
|
|
async function importJwk(alg, jwk) {
|
|
const { ext, key_ops, use, ...key } = jwk;
|
|
return crypto.subtle.importKey('jwk', key, algToSubtle(alg), true, ['verify']);
|
|
}
|
|
export async function deviceAuthorizationRequest(as, client, clientAuthentication, parameters, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
const url = resolveEndpoint(as, 'device_authorization_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const body = new URLSearchParams(parameters);
|
|
body.set('client_id', client.client_id);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
return authenticatedRequest(as, client, clientAuthentication, url, body, headers, options);
|
|
}
|
|
export async function processDeviceAuthorizationResponse(as, client, response) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 200, 'Device Authorization Endpoint');
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.device_code, '"response" body "device_code" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
assertString(json.user_code, '"response" body "user_code" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
assertString(json.verification_uri, '"response" body "verification_uri" property', INVALID_RESPONSE, { body: json });
|
|
let expiresIn = typeof json.expires_in !== 'number' ? parseFloat(json.expires_in) : json.expires_in;
|
|
assertNumber(expiresIn, true, '"response" body "expires_in" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
json.expires_in = expiresIn;
|
|
if (json.verification_uri_complete !== undefined) {
|
|
assertString(json.verification_uri_complete, '"response" body "verification_uri_complete" property', INVALID_RESPONSE, { body: json });
|
|
}
|
|
if (json.interval !== undefined) {
|
|
assertNumber(json.interval, false, '"response" body "interval" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
return json;
|
|
}
|
|
export async function deviceCodeGrantRequest(as, client, clientAuthentication, deviceCode, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(deviceCode, '"deviceCode"');
|
|
const parameters = new URLSearchParams(options?.additionalParameters);
|
|
parameters.set('device_code', deviceCode);
|
|
return tokenEndpointRequest(as, client, clientAuthentication, 'urn:ietf:params:oauth:grant-type:device_code', parameters, options);
|
|
}
|
|
export async function processDeviceCodeResponse(as, client, response, options) {
|
|
return processGenericAccessTokenResponse(as, client, response, undefined, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
export async function generateKeyPair(alg, options) {
|
|
assertString(alg, '"alg"');
|
|
const algorithm = algToSubtle(alg);
|
|
if (alg.startsWith('PS') || alg.startsWith('RS')) {
|
|
Object.assign(algorithm, {
|
|
modulusLength: options?.modulusLength ?? 2048,
|
|
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
});
|
|
}
|
|
return crypto.subtle.generateKey(algorithm, options?.extractable ?? false, [
|
|
'sign',
|
|
'verify',
|
|
]);
|
|
}
|
|
function normalizeHtu(htu) {
|
|
const url = new URL(htu);
|
|
url.search = '';
|
|
url.hash = '';
|
|
return url.href;
|
|
}
|
|
async function validateDPoP(request, accessToken, accessTokenClaims, options) {
|
|
const headerValue = request.headers.get('dpop');
|
|
if (headerValue === null) {
|
|
throw OPE('operation indicated DPoP use but the request has no DPoP HTTP Header', INVALID_REQUEST, { headers: request.headers });
|
|
}
|
|
if (request.headers.get('authorization')?.toLowerCase().startsWith('dpop ') === false) {
|
|
throw OPE(`operation indicated DPoP use but the request's Authorization HTTP Header scheme is not DPoP`, INVALID_REQUEST, { headers: request.headers });
|
|
}
|
|
if (typeof accessTokenClaims.cnf?.jkt !== 'string') {
|
|
throw OPE('operation indicated DPoP use but the JWT Access Token has no jkt confirmation claim', INVALID_REQUEST, { claims: accessTokenClaims });
|
|
}
|
|
const clockSkew = getClockSkew(options);
|
|
const proof = await validateJwt(headerValue, checkSigningAlgorithm.bind(undefined, options?.signingAlgorithms, undefined, supported), clockSkew, getClockTolerance(options), undefined)
|
|
.then(checkJwtType.bind(undefined, 'dpop+jwt'))
|
|
.then(validatePresence.bind(undefined, ['iat', 'jti', 'ath', 'htm', 'htu']));
|
|
const now = epochTime() + clockSkew;
|
|
const diff = Math.abs(now - proof.claims.iat);
|
|
if (diff > 300) {
|
|
throw OPE('DPoP Proof iat is not recent enough', JWT_TIMESTAMP_CHECK, {
|
|
now,
|
|
claims: proof.claims,
|
|
claim: 'iat',
|
|
});
|
|
}
|
|
if (proof.claims.htm !== request.method) {
|
|
throw OPE('DPoP Proof htm mismatch', JWT_CLAIM_COMPARISON, {
|
|
expected: request.method,
|
|
claims: proof.claims,
|
|
claim: 'htm',
|
|
});
|
|
}
|
|
if (typeof proof.claims.htu !== 'string' ||
|
|
normalizeHtu(proof.claims.htu) !== normalizeHtu(request.url)) {
|
|
throw OPE('DPoP Proof htu mismatch', JWT_CLAIM_COMPARISON, {
|
|
expected: normalizeHtu(request.url),
|
|
claims: proof.claims,
|
|
claim: 'htu',
|
|
});
|
|
}
|
|
{
|
|
const expected = b64u(await crypto.subtle.digest('SHA-256', buf(accessToken)));
|
|
if (proof.claims.ath !== expected) {
|
|
throw OPE('DPoP Proof ath mismatch', JWT_CLAIM_COMPARISON, {
|
|
expected,
|
|
claims: proof.claims,
|
|
claim: 'ath',
|
|
});
|
|
}
|
|
}
|
|
{
|
|
const expected = await calculateJwkThumbprint(proof.header.jwk);
|
|
if (accessTokenClaims.cnf.jkt !== expected) {
|
|
throw OPE('JWT Access Token confirmation mismatch', JWT_CLAIM_COMPARISON, {
|
|
expected,
|
|
claims: accessTokenClaims,
|
|
claim: 'cnf.jkt',
|
|
});
|
|
}
|
|
}
|
|
const { 0: protectedHeader, 1: payload, 2: encodedSignature } = headerValue.split('.');
|
|
const signature = b64u(encodedSignature);
|
|
const { jwk, alg } = proof.header;
|
|
if (!jwk) {
|
|
throw OPE('DPoP Proof is missing the jwk header parameter', INVALID_REQUEST, {
|
|
header: proof.header,
|
|
});
|
|
}
|
|
const key = await importJwk(alg, jwk);
|
|
if (key.type !== 'public') {
|
|
throw OPE('DPoP Proof jwk header parameter must contain a public key', INVALID_REQUEST, {
|
|
header: proof.header,
|
|
});
|
|
}
|
|
await validateJwsSignature(protectedHeader, payload, key, signature);
|
|
}
|
|
export async function validateJwtAccessToken(as, request, expectedAudience, options) {
|
|
assertAs(as);
|
|
if (!looseInstanceOf(request, Request)) {
|
|
throw CodedTypeError('"request" must be an instance of Request', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
assertString(expectedAudience, '"expectedAudience"');
|
|
const authorization = request.headers.get('authorization');
|
|
if (authorization === null) {
|
|
throw OPE('"request" is missing an Authorization HTTP Header', INVALID_REQUEST, {
|
|
headers: request.headers,
|
|
});
|
|
}
|
|
let { 0: scheme, 1: accessToken, length } = authorization.split(' ');
|
|
scheme = scheme.toLowerCase();
|
|
switch (scheme) {
|
|
case 'dpop':
|
|
case 'bearer':
|
|
break;
|
|
default:
|
|
throw new UnsupportedOperationError('unsupported Authorization HTTP Header scheme', {
|
|
cause: { headers: request.headers },
|
|
});
|
|
}
|
|
if (length !== 2) {
|
|
throw OPE('invalid Authorization HTTP Header format', INVALID_REQUEST, {
|
|
headers: request.headers,
|
|
});
|
|
}
|
|
const requiredClaims = [
|
|
'iss',
|
|
'exp',
|
|
'aud',
|
|
'sub',
|
|
'iat',
|
|
'jti',
|
|
'client_id',
|
|
];
|
|
if (options?.requireDPoP || scheme === 'dpop' || request.headers.has('dpop')) {
|
|
requiredClaims.push('cnf');
|
|
}
|
|
const { claims, header } = await validateJwt(accessToken, checkSigningAlgorithm.bind(undefined, options?.signingAlgorithms, undefined, supported), getClockSkew(options), getClockTolerance(options), undefined)
|
|
.then(checkJwtType.bind(undefined, 'at+jwt'))
|
|
.then(validatePresence.bind(undefined, requiredClaims))
|
|
.then(validateIssuer.bind(undefined, as))
|
|
.then(validateAudience.bind(undefined, expectedAudience))
|
|
.catch(reassignRSCode);
|
|
for (const claim of ['client_id', 'jti', 'sub']) {
|
|
if (typeof claims[claim] !== 'string') {
|
|
throw OPE(`unexpected JWT "${claim}" claim type`, INVALID_REQUEST, { claims });
|
|
}
|
|
}
|
|
if ('cnf' in claims) {
|
|
if (!isJsonObject(claims.cnf)) {
|
|
throw OPE('unexpected JWT "cnf" (confirmation) claim value', INVALID_REQUEST, { claims });
|
|
}
|
|
const { 0: cnf, length } = Object.keys(claims.cnf);
|
|
if (length) {
|
|
if (length !== 1) {
|
|
throw new UnsupportedOperationError('multiple confirmation claims are not supported', {
|
|
cause: { claims },
|
|
});
|
|
}
|
|
if (cnf !== 'jkt') {
|
|
throw new UnsupportedOperationError('unsupported JWT Confirmation method', {
|
|
cause: { claims },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const { 0: protectedHeader, 1: payload, 2: encodedSignature } = accessToken.split('.');
|
|
const signature = b64u(encodedSignature);
|
|
const key = await getPublicSigKeyFromIssuerJwksUri(as, options, header);
|
|
await validateJwsSignature(protectedHeader, payload, key, signature);
|
|
if (options?.requireDPoP ||
|
|
scheme === 'dpop' ||
|
|
claims.cnf?.jkt !== undefined ||
|
|
request.headers.has('dpop')) {
|
|
await validateDPoP(request, accessToken, claims, options).catch(reassignRSCode);
|
|
}
|
|
return claims;
|
|
}
|
|
function reassignRSCode(err) {
|
|
if (err instanceof OperationProcessingError && err?.code === INVALID_REQUEST) {
|
|
err.code = INVALID_RESPONSE;
|
|
}
|
|
throw err;
|
|
}
|
|
export async function backchannelAuthenticationRequest(as, client, clientAuthentication, parameters, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
const url = resolveEndpoint(as, 'backchannel_authentication_endpoint', client.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const body = new URLSearchParams(parameters);
|
|
body.set('client_id', client.client_id);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
return authenticatedRequest(as, client, clientAuthentication, url, body, headers, options);
|
|
}
|
|
export async function processBackchannelAuthenticationResponse(as, client, response) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 200, 'Backchannel Authentication Endpoint');
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.auth_req_id, '"response" body "auth_req_id" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
let expiresIn = typeof json.expires_in !== 'number' ? parseFloat(json.expires_in) : json.expires_in;
|
|
assertNumber(expiresIn, true, '"response" body "expires_in" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
json.expires_in = expiresIn;
|
|
if (json.interval !== undefined) {
|
|
assertNumber(json.interval, false, '"response" body "interval" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
return json;
|
|
}
|
|
export async function backchannelAuthenticationGrantRequest(as, client, clientAuthentication, authReqId, options) {
|
|
assertAs(as);
|
|
assertClient(client);
|
|
assertString(authReqId, '"authReqId"');
|
|
const parameters = new URLSearchParams(options?.additionalParameters);
|
|
parameters.set('auth_req_id', authReqId);
|
|
return tokenEndpointRequest(as, client, clientAuthentication, 'urn:openid:params:grant-type:ciba', parameters, options);
|
|
}
|
|
export async function processBackchannelAuthenticationGrantResponse(as, client, response, options) {
|
|
return processGenericAccessTokenResponse(as, client, response, undefined, options?.[jweDecrypt], options?.recognizedTokenTypes);
|
|
}
|
|
export async function dynamicClientRegistrationRequest(as, metadata, options) {
|
|
assertAs(as);
|
|
const url = resolveEndpoint(as, 'registration_endpoint', metadata.use_mtls_endpoint_aliases, options?.[allowInsecureRequests] !== true);
|
|
const headers = prepareHeaders(options?.headers);
|
|
headers.set('accept', 'application/json');
|
|
headers.set('content-type', 'application/json');
|
|
const method = 'POST';
|
|
if (options?.DPoP) {
|
|
assertDPoP(options.DPoP);
|
|
await options.DPoP.addProof(url, headers, method, options.initialAccessToken);
|
|
}
|
|
if (options?.initialAccessToken) {
|
|
headers.set('authorization', `${headers.has('dpop') ? 'DPoP' : 'Bearer'} ${options.initialAccessToken}`);
|
|
}
|
|
const response = await (options?.[customFetch] || fetch)(url.href, {
|
|
body: JSON.stringify(metadata),
|
|
headers: Object.fromEntries(headers.entries()),
|
|
method,
|
|
redirect: 'manual',
|
|
signal: signal(url, options?.signal),
|
|
});
|
|
options?.DPoP?.cacheNonce(response, url);
|
|
return response;
|
|
}
|
|
export async function processDynamicClientRegistrationResponse(response) {
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
await checkOAuthBodyError(response, 201, 'Dynamic Client Registration Endpoint');
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.client_id, '"response" body "client_id" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
if (json.client_secret !== undefined) {
|
|
assertString(json.client_secret, '"response" body "client_secret" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
if (json.client_secret) {
|
|
assertNumber(json.client_secret_expires_at, true, '"response" body "client_secret_expires_at" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
}
|
|
return json;
|
|
}
|
|
export async function resourceDiscoveryRequest(resourceIdentifier, options) {
|
|
return performDiscovery(resourceIdentifier, 'resourceIdentifier', (url) => {
|
|
prependWellKnown(url, '.well-known/oauth-protected-resource', true);
|
|
return url;
|
|
}, options);
|
|
}
|
|
export async function processResourceDiscoveryResponse(expectedResourceIdentifier, response) {
|
|
const expected = expectedResourceIdentifier;
|
|
if (!(expected instanceof URL) && expected !== _nodiscoverycheck) {
|
|
throw CodedTypeError('"expectedResourceIdentifier" must be an instance of URL', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (!looseInstanceOf(response, Response)) {
|
|
throw CodedTypeError('"response" must be an instance of Response', ERR_INVALID_ARG_TYPE);
|
|
}
|
|
if (response.status !== 200) {
|
|
throw OPE('"response" is not a conform Resource Server Metadata response (unexpected HTTP status code)', RESPONSE_IS_NOT_CONFORM, response);
|
|
}
|
|
assertReadableResponse(response);
|
|
const json = await getResponseJsonBody(response);
|
|
assertString(json.resource, '"response" body "resource" property', INVALID_RESPONSE, {
|
|
body: json,
|
|
});
|
|
if (expected !== _nodiscoverycheck && new URL(json.resource).href !== expected.href) {
|
|
throw OPE('"response" body "resource" property does not match the expected value', JSON_ATTRIBUTE_COMPARISON, { expected: expected.href, body: json, attribute: 'resource' });
|
|
}
|
|
return json;
|
|
}
|
|
async function getResponseJsonBody(response, check = assertApplicationJson) {
|
|
let json;
|
|
try {
|
|
json = await response.json();
|
|
}
|
|
catch (cause) {
|
|
check(response);
|
|
throw OPE('failed to parse "response" body as JSON', PARSE_ERROR, cause);
|
|
}
|
|
if (!isJsonObject(json)) {
|
|
throw OPE('"response" body must be a top level object', INVALID_RESPONSE, { body: json });
|
|
}
|
|
return json;
|
|
}
|
|
export const _nopkce = nopkce;
|
|
export const _nodiscoverycheck = Symbol();
|
|
export const _expectedIssuer = Symbol();
|
|
//# sourceMappingURL=index.js.map
|