From 6b6b4967bfd04d7c4193469f27e0fc5ffeaaf5b9 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 11 Jan 2024 10:16:14 +0100 Subject: [PATCH] feat: add experimental support for FAPI 1.0 --- .github/workflows/conformance.yml | 4 + conformance/ava.config.ts | 16 ++ .../fapi/encrypted-idtoken-usingrsa15.ts | 5 + conformance/fapi/encrypted-idtoken.ts | 5 + conformance/fapi/iat-is-week-in-past.ts | 15 +- conformance/fapi/invalid-chash.ts | 5 + conformance/fapi/invalid-missing-shash.ts | 5 + conformance/fapi/invalid-shash.ts | 5 + conformance/run-certification.sh | 32 ++- conformance/runner.ts | 90 ++++++- docs/README.md | 4 + ...ental_validateDetachedSignatureResponse.md | 33 +++ src/index.ts | 242 +++++++++++++++++- 13 files changed, 430 insertions(+), 31 deletions(-) create mode 100644 conformance/fapi/encrypted-idtoken-usingrsa15.ts create mode 100644 conformance/fapi/encrypted-idtoken.ts create mode 100644 conformance/fapi/invalid-chash.ts create mode 100644 conformance/fapi/invalid-missing-shash.ts create mode 100644 conformance/fapi/invalid-shash.ts create mode 100644 docs/functions/experimental_validateDetachedSignatureResponse.md diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 96e6df6a..54b493ca 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -34,6 +34,10 @@ jobs: - plan: fapi1-advanced-final-client-test-plan variant: client_auth_type: 'mtls' + - plan: fapi1-advanced-final-client-test-plan + variant: + fapi_response_mode: 'plain_response' + fapi_auth_request_method: 'by_value' # FAPI 2.0 Security Profile ID2 - plan: fapi2-security-profile-id2-client-test-plan diff --git a/conformance/ava.config.ts b/conformance/ava.config.ts index c0109382..c5cff0f1 100644 --- a/conformance/ava.config.ts +++ b/conformance/ava.config.ts @@ -115,6 +115,18 @@ function makePublicJwks(def: any) { return client } +function pushEncryptionKey(def: any) { + const client = structuredClone(def) + const key = client.jwks.keys[0] + client.jwks.keys.push({ + ...key, + kid: `enc-${key.kid}`, + use: 'enc', + alg: 'RSA-OEAP', + }) + return client +} + function ensureTestFile(path: string, name: string) { if (!exists(path)) { writeFileSync( @@ -178,6 +190,10 @@ export default async () => { { ...configuration, client: makePublicJwks(clientConfig), + client2: { + ...pushEncryptionKey(makePublicJwks(clientConfig)), + id_token_encrypted_response_alg: 'RSA-OAEP', + }, }, variant, ) diff --git a/conformance/fapi/encrypted-idtoken-usingrsa15.ts b/conformance/fapi/encrypted-idtoken-usingrsa15.ts new file mode 100644 index 00000000..95cff0ec --- /dev/null +++ b/conformance/fapi/encrypted-idtoken-usingrsa15.ts @@ -0,0 +1,5 @@ +import { test, red, modules } from '../runner.js' + +for (const module of modules('encrypted-idtoken-usingrsa15')) { + test.serial(red, module, 'failed to decrypt ID Token') +} diff --git a/conformance/fapi/encrypted-idtoken.ts b/conformance/fapi/encrypted-idtoken.ts new file mode 100644 index 00000000..3c11874e --- /dev/null +++ b/conformance/fapi/encrypted-idtoken.ts @@ -0,0 +1,5 @@ +import { test, green, modules } from '../runner.js' + +for (const module of modules('encrypted-idtoken')) { + test.serial(green, module) +} diff --git a/conformance/fapi/iat-is-week-in-past.ts b/conformance/fapi/iat-is-week-in-past.ts index 00f5cb03..a1dc8cbf 100644 --- a/conformance/fapi/iat-is-week-in-past.ts +++ b/conformance/fapi/iat-is-week-in-past.ts @@ -1,3 +1,14 @@ -import test from 'ava' +import { test, red, modules, variant } from '../runner.js' -test.todo('iat-is-week-in-past') +for (const module of modules('iat-is-week-in-past')) { + if (variant.fapi_response_mode === 'jarm') { + // TODO: https://gitlab.com/openid/conformance-suite/-/merge_requests/1368 + test.todo('iat-is-week-in-past') + } else { + test.serial( + red, + module, + 'unexpected JWT "iat" (issued at) claim value, it is too far in the past', + ) + } +} diff --git a/conformance/fapi/invalid-chash.ts b/conformance/fapi/invalid-chash.ts new file mode 100644 index 00000000..c51ecc7a --- /dev/null +++ b/conformance/fapi/invalid-chash.ts @@ -0,0 +1,5 @@ +import { test, red, modules } from '../runner.js' + +for (const module of modules('invalid-chash')) { + test.serial(red, module, 'invalid ID Token "c_hash" (code hash) claim value') +} diff --git a/conformance/fapi/invalid-missing-shash.ts b/conformance/fapi/invalid-missing-shash.ts new file mode 100644 index 00000000..45dc94ce --- /dev/null +++ b/conformance/fapi/invalid-missing-shash.ts @@ -0,0 +1,5 @@ +import { test, red, modules } from '../runner.js' + +for (const module of modules('invalid-missing-shash')) { + test.serial(red, module, 'invalid ID Token "s_hash" (state hash) claim value') +} diff --git a/conformance/fapi/invalid-shash.ts b/conformance/fapi/invalid-shash.ts new file mode 100644 index 00000000..f761b0a8 --- /dev/null +++ b/conformance/fapi/invalid-shash.ts @@ -0,0 +1,5 @@ +import { test, red, modules } from '../runner.js' + +for (const module of modules('invalid-shash')) { + test.serial(red, module, 'invalid ID Token "s_hash" (state hash) claim value') +} diff --git a/conformance/run-certification.sh b/conformance/run-certification.sh index d6ed2df1..9dc3a0d7 100755 --- a/conformance/run-certification.sh +++ b/conformance/run-certification.sh @@ -2,7 +2,8 @@ set -e -# Helper function to run a conformance test plan +declare -a pids + run_conformance() { local plan_name=$1 local variant=$2 @@ -13,21 +14,42 @@ run_conformance() { echo "====================================================================" } +cleanup() { + for pid in "${pids[@]}"; do + kill "$pid" + done + exit 1 +} + +# Trap the exit signal +trap cleanup EXIT + # Basic RP export PLAN_NAME=oidcc-client-basic-certification-test-plan export VARIANT='{}' run_conformance "$PLAN_NAME" "$VARIANT" & +pids+=($!) export CLIENT_AUTH_TYPES=("mtls" "private_key_jwt") export FAPI_CLIENT_TYPES=("oidc" "plain_oauth") # FAPI 1.0 Advanced export PLAN_NAME=fapi1-advanced-final-client-test-plan +export FAPI_RESPONSE_MODES=("plain_response" "jarm") +export FAPI_AUTH_REQUEST_METHODS=("pushed" "by_value") for CLIENT_AUTH_TYPE in "${CLIENT_AUTH_TYPES[@]}"; do - for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do - export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}" - run_conformance "$PLAN_NAME" "$VARIANT" & + for FAPI_RESPONSE_MODE in "${FAPI_RESPONSE_MODES[@]}"; do + for FAPI_AUTH_REQUEST_METHOD in "${FAPI_AUTH_REQUEST_METHODS[@]}"; do + for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do + if [[ "$FAPI_CLIENT_TYPE" == "plain_oauth" && "$FAPI_RESPONSE_MODE" != "jarm" ]]; then + continue + fi + export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"fapi_response_mode\":\"$FAPI_RESPONSE_MODE\",\"fapi_auth_request_method\":\"$FAPI_AUTH_REQUEST_METHOD\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}" + run_conformance "$PLAN_NAME" "$VARIANT" & + pids+=($!) + done + done done done @@ -41,10 +63,10 @@ for PLAN_NAME in "${PLAN_NAMES[@]}"; do for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"sender_constrain\":\"$SENDER_CONSTRAIN\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}" run_conformance "$PLAN_NAME" "$VARIANT" & + pids+=($!) done done done done -# Wait for all runs to finish wait diff --git a/conformance/runner.ts b/conformance/runner.ts index ee07ad5d..04625f5e 100644 --- a/conformance/runner.ts +++ b/conformance/runner.ts @@ -1,5 +1,12 @@ import anyTest, { type TestFn } from 'ava' -import { importJWK, type JWK, calculateJwkThumbprint, exportJWK } from 'jose' +import { + importJWK, + type JWK, + calculateJwkThumbprint, + exportJWK, + decodeProtectedHeader, + compactDecrypt, +} from 'jose' import * as undici from 'undici' export const test = anyTest as TestFn<{ instance: Test }> @@ -70,8 +77,8 @@ export function modules(name: string): ModulePrescription[] { ) } -function usesJarm(plan: Plan) { - return plan.name.startsWith('fapi2-message-signing') || plan.name.startsWith('fapi1') +function usesJarm(variant: Record) { + return variant.fapi_response_mode === 'jarm' } function usesDpop(variant: Record) { @@ -79,7 +86,7 @@ function usesDpop(variant: Record) { } function usesPar(plan: Plan) { - return plan.name.startsWith('fapi') + return plan.name.startsWith('fapi2') || variant.fapi_auth_request_method === 'pushed' } function usesRequestObject(planName: string, variant: Record) { @@ -106,6 +113,30 @@ function requiresState(planName: string, variant: Record) { return planName.startsWith('fapi1') && !getScope(variant).includes('openid') } +function responseType(planName: string, variant: Record) { + if (!planName.startsWith('fapi1')) { + return 'code' + } + + return variant.fapi_response_mode === 'jarm' ? 'code' : 'code id_token' +} + +async function decryptIdToken(jwe: string) { + return new TextDecoder().decode( + ( + await compactDecrypt( + jwe, + await importPrivateKey('RSA-OAEP', configuration.client.jwks.keys[0]), + { + keyManagementAlgorithms: ['RSA-OAEP'], + }, + ).catch((cause) => { + throw new oauth.OperationProcessingError('failed to decrypt ID Token', { cause }) + }) + ).plaintext, + ) +} + export const green = test.macro({ async exec(t, module: ModulePrescription) { t.timeout(15000) @@ -116,7 +147,7 @@ export const green = test.macro({ t.log('Test ID', instance.id) t.log('Test Name', instance.name) - const variant = { + const variant: Record = { ...conformance.variant, ...module.variant, } @@ -193,6 +224,7 @@ export const green = test.macro({ } } + const response_type = responseType(plan.name, variant) const scope = getScope(variant) const code_verifier = oauth.generateRandomCodeVerifier() const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier) @@ -200,9 +232,11 @@ export const green = test.macro({ let nonce = requiresNonce(plan.name, variant) ? oauth.generateRandomNonce() : oauth.expectNoNonce - let state = requiresState(plan.name, variant) - ? oauth.generateRandomState() - : oauth.expectNoState + let state = + requiresState(plan.name, variant) || + (plan.name.startsWith('fapi1') && variant.fapi_response_mode !== 'jarm') + ? oauth.generateRandomState() + : oauth.expectNoState let authorizationUrl = new URL(as.authorization_endpoint!) if (!usesRequestObject(plan.name, variant)) { @@ -210,7 +244,7 @@ export const green = test.macro({ authorizationUrl.searchParams.set('code_challenge', code_challenge) authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) authorizationUrl.searchParams.set('redirect_uri', configuration.client.redirect_uri) - authorizationUrl.searchParams.set('response_type', 'code') + authorizationUrl.searchParams.set('response_type', response_type) authorizationUrl.searchParams.set('scope', scope) if (typeof nonce === 'string') { authorizationUrl.searchParams.set('nonce', nonce) @@ -224,7 +258,7 @@ export const green = test.macro({ params.set('code_challenge', code_challenge) params.set('code_challenge_method', code_challenge_method) params.set('redirect_uri', configuration.client.redirect_uri) - params.set('response_type', 'code') + params.set('response_type', response_type) params.set('scope', scope) if (typeof nonce === 'string') { params.set('nonce', nonce) @@ -241,7 +275,7 @@ export const green = test.macro({ await oauth.issueRequestObject(as, client, params, { kid: jwk.kid, key: privateKey }), ) authorizationUrl.searchParams.set('scope', scope) - authorizationUrl.searchParams.set('response_type', 'code') + authorizationUrl.searchParams.set('response_type', response_type) } let DPoP!: CryptoKeyPair @@ -301,15 +335,28 @@ export const green = test.macro({ throw new Error() } - const currentUrl = new URL(authorization_endpoint_response_redirect) + let currentUrl: URL | URLSearchParams = new URL(authorization_endpoint_response_redirect) let sub: string let access_token: string { let params: ReturnType - if (usesJarm(plan)) { + if (usesJarm(variant)) { params = await oauth.validateJwtAuthResponse(as, client, currentUrl, state) + } else if (response_type === 'code id_token') { + currentUrl = new URLSearchParams(currentUrl.hash.slice(1)) + const idToken = currentUrl.get('id_token')! + if (decodeProtectedHeader(idToken).enc) { + currentUrl.set('id_token', await decryptIdToken(idToken)) + } + params = await oauth.experimental_validateDetachedSignatureResponse( + as, + client, + currentUrl, + nonce, + state, + ) } else { params = oauth.validateAuthResponse(as, client, currentUrl, state) } @@ -344,6 +391,23 @@ export const green = test.macro({ throw new Error() } + if (response_type === 'code id_token') { + try { + const body = await response.clone().json() + const { id_token } = body + if (decodeProtectedHeader(id_token).enc) { + const newResponse = new Response( + JSON.stringify({ + ...body, + id_token: await decryptIdToken(id_token), + }), + response, + ) + response = newResponse + } + } catch {} + } + let result: | oauth.OAuth2TokenEndpointResponse | oauth.OpenIDTokenEndpointResponse diff --git a/docs/README.md b/docs/README.md index 30a79f8d..c0e2ddd9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,6 +62,10 @@ - [OperationProcessingError](classes/OperationProcessingError.md) - [UnsupportedOperationError](classes/UnsupportedOperationError.md) +### FAPI 1.0 Advanced + +- [experimental\_validateDetachedSignatureResponse](functions/experimental_validateDetachedSignatureResponse.md) + ### JWT Secured Authorization Response Mode for OAuth 2.0 (JARM) - [validateJwtAuthResponse](functions/validateJwtAuthResponse.md) diff --git a/docs/functions/experimental_validateDetachedSignatureResponse.md b/docs/functions/experimental_validateDetachedSignatureResponse.md new file mode 100644 index 00000000..fe062fcc --- /dev/null +++ b/docs/functions/experimental_validateDetachedSignatureResponse.md @@ -0,0 +1,33 @@ +# Function: experimental\_validateDetachedSignatureResponse + +[💗 Help the project](https://github.com/sponsors/panva) + +▸ **experimental_validateDetachedSignatureResponse**(`as`, `client`, `parameters`, `expectedNonce`, `expectedState?`, `maxAge?`, `options?`): [`Promise`]( https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise )\<[`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) \| [`OAuth2Error`](../interfaces/OAuth2Error.md)\> + +This is an experimental feature, it is not subject to semantic versioning rules. Non-backward +compatible changes or removal may occur in any future release. + +Same as [validateAuthResponse](validateAuthResponse.md) but for FAPI 1.0 Advanced Detached Signature authorization +responses. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `as` | [`AuthorizationServer`](../interfaces/AuthorizationServer.md) | Authorization Server Metadata. | +| `client` | [`Client`](../interfaces/Client.md) | Client Metadata. | +| `parameters` | [`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) | Authorization Response. | +| `expectedNonce` | `string` | Expected ID Token `nonce` claim value. | +| `expectedState?` | `string` \| typeof [`expectNoState`](../variables/expectNoState.md) | Expected `state` parameter value. Default is [expectNoState](../variables/expectNoState.md). | +| `maxAge?` | `number` \| typeof [`skipAuthTimeCheck`](../variables/skipAuthTimeCheck.md) | ID Token [`auth_time`](../interfaces/IDToken.md#auth_time) claim value will be checked to be present and conform to the `maxAge` value. Use of this option is required if you sent a `max_age` parameter in an authorization request. Default is [`client.default_max_age`](../interfaces/Client.md#default_max_age) and falls back to [skipAuthTimeCheck](../variables/skipAuthTimeCheck.md). | +| `options?` | [`HttpRequestOptions`](../interfaces/HttpRequestOptions.md) | - | + +#### Returns + +[`Promise`]( https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise )\<[`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) \| [`OAuth2Error`](../interfaces/OAuth2Error.md)\> + +Validated Authorization Response parameters or Authorization Error Response. + +**`See`** + +[Financial-grade API Security Profile 1.0 - Part 2: Advanced](https://openid.net/specs/openid-financial-api-part-2-1_0.html#id-token-as-detached-signature) diff --git a/src/index.ts b/src/index.ts index c8cf6a1f..7bd3070d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2521,14 +2521,17 @@ export async function processRefreshTokenResponse( return processGenericAccessTokenResponse(as, client, response) } -function validateOptionalAudience(expected: string, result: ParsedJWT) { +function validateOptionalAudience( + expected: string, + result: Awaited>, +) { if (result.claims.aud !== undefined) { return validateAudience(expected, result) } return result } -function validateAudience(expected: string, result: ParsedJWT) { +function validateAudience(expected: string, result: Awaited>) { if (Array.isArray(result.claims.aud)) { if (!result.claims.aud.includes(expected)) { throw new OPE('unexpected JWT "aud" (audience) claim value') @@ -2540,14 +2543,14 @@ function validateAudience(expected: string, result: ParsedJWT) { return result } -function validateOptionalIssuer(expected: string, result: ParsedJWT) { +function validateOptionalIssuer(expected: string, result: Awaited>) { if (result.claims.iss !== undefined) { return validateIssuer(expected, result) } return result } -function validateIssuer(expected: string, result: ParsedJWT) { +function validateIssuer(expected: string, result: Awaited>) { if (result.claims.iss !== expected) { throw new OPE('unexpected JWT "iss" (issuer) claim value') } @@ -2654,18 +2657,24 @@ interface ParsedJWT { signature: Uint8Array } -const claimNames = { +const idTokenClaimNames = { aud: 'audience', exp: 'expiration time', iat: 'issued at', iss: 'issuer', sub: 'subject', + nonce: 'nonce', + s_hash: 'state hash', + c_hash: 'code hash', } -function validatePresence(required: (keyof typeof claimNames)[], result: ParsedJWT) { +function validatePresence( + required: (keyof typeof idTokenClaimNames)[], + result: Awaited>, +) { for (const claim of required) { if (result.claims[claim] === undefined) { - throw new OPE(`JWT "${claim}" (${claimNames[claim]}) claim missing`) + throw new OPE(`JWT "${claim}" (${idTokenClaimNames[claim]}) claim missing`) } } return result @@ -2853,7 +2862,7 @@ export async function processAuthorizationCodeOAuth2Response( return result } -function checkJwtType(expected: string, result: ParsedJWT) { +function checkJwtType(expected: string, result: Awaited>) { if (typeof result.header.typ !== 'string' || normalizeTyp(result.header.typ) !== expected) { throw new OPE('unexpected JWT "typ" header parameter value') } @@ -3316,7 +3325,7 @@ async function validateJwt( getKey: ((h: CompactJWSHeaderParameters) => Promise) | typeof noSignatureCheck, clockSkew: number, clockTolerance: number, -): Promise { +): Promise { const { 0: protectedHeader, 1: payload, 2: encodedSignature, length } = jws.split('.') if (length === 5) { throw new UnsupportedOperationError('JWE structure JWTs are not supported') @@ -3342,8 +3351,9 @@ async function validateJwt( } const signature = b64u(encodedSignature) + let key!: CryptoKey if (getKey !== noSignatureCheck) { - const key = await getKey(header) + key = await getKey(header) const input = `${protectedHeader}.${payload}` const verified = await crypto.subtle.verify(keyToSubtle(key), key, signature, buf(input)) if (!verified) { @@ -3401,7 +3411,7 @@ async function validateJwt( } } - return { header, claims, signature } + return { header, claims, signature, key } } /** @@ -3473,6 +3483,216 @@ export async function validateJwtAuthResponse( return validateAuthResponse(as, client, result, expectedState) } +async function idTokenHash(alg: JWSAlgorithm, data: string, key: CryptoKey) { + let algorithm: string + switch (alg) { + case 'RS256': // Fall through + case 'PS256': // Fall through + case 'ES256': + algorithm = 'SHA-256' + break + case 'RS384': // Fall through + case 'PS384': // Fall through + case 'ES384': + algorithm = 'SHA-384' + break + case 'RS512': // Fall through + case 'PS512': // Fall through + case 'ES512': + algorithm = 'SHA-512' + break + case 'EdDSA': + if (key.algorithm.name === 'Ed25519') { + algorithm = 'SHA-512' + break + } + throw new UnsupportedOperationError() + default: + throw new UnsupportedOperationError() + } + + const digest = await crypto.subtle.digest(algorithm, buf(data)) + return b64u(digest.slice(0, digest.byteLength / 2)) +} + +async function idTokenHashMatches(data: string, actual: string, alg: JWSAlgorithm, key: CryptoKey) { + const expected = await idTokenHash(alg, data, key) + return actual === expected +} + +/** + * This is an experimental feature, it is not subject to semantic versioning rules. Non-backward + * compatible changes or removal may occur in any future release. + * + * Same as {@link validateAuthResponse} but for FAPI 1.0 Advanced Detached Signature authorization + * responses. + * + * @param as Authorization Server Metadata. + * @param client Client Metadata. + * @param parameters Authorization Response. + * @param expectedNonce Expected ID Token `nonce` claim value. + * @param expectedState Expected `state` parameter value. Default is {@link expectNoState}. + * @param maxAge ID Token {@link IDToken.auth_time `auth_time`} claim value will be checked to be + * present and conform to the `maxAge` value. Use of this option is required if you sent a + * `max_age` parameter in an authorization request. Default is + * {@link Client.default_max_age `client.default_max_age`} and falls back to + * {@link skipAuthTimeCheck}. + * + * @returns Validated Authorization Response parameters or Authorization Error Response. + * + * @group FAPI 1.0 Advanced + * + * @see [Financial-grade API Security Profile 1.0 - Part 2: Advanced](https://openid.net/specs/openid-financial-api-part-2-1_0.html#id-token-as-detached-signature) + */ +export async function experimental_validateDetachedSignatureResponse( + as: AuthorizationServer, + client: Client, + parameters: URLSearchParams, + expectedNonce: string, + expectedState?: string | typeof expectNoState, + maxAge?: number | typeof skipAuthTimeCheck, + options?: HttpRequestOptions, +): Promise { + assertAs(as) + assertClient(client) + + if (!(parameters instanceof URLSearchParams)) { + throw new TypeError('"parameters" must be an instance of URLSearchParams') + } + + parameters = new URLSearchParams(parameters) + + const id_token = getURLSearchParameter(parameters, 'id_token') + parameters.delete('id_token') + + switch (expectedState) { + case undefined: + case expectNoState: + break + default: + if (!validateString(expectedState)) { + throw new TypeError('"expectedState" must be a non-empty string') + } + } + + const result = validateAuthResponse( + { + ...as, + authorization_response_iss_parameter_supported: false, + }, + client, + parameters, + expectedState, + ) + + if (isOAuth2Error(result)) { + return result + } + + if (!id_token) { + throw new OPE('"parameters" does not contain an ID Token') + } + const code = getURLSearchParameter(parameters, 'code') + if (!code) { + throw new OPE('"parameters" does not contain an Authorization Code') + } + + if (typeof as.jwks_uri !== 'string') { + throw new TypeError('"as.jwks_uri" must be a string') + } + + const requiredClaims: (keyof typeof idTokenClaimNames)[] = [ + 'aud', + 'exp', + 'iat', + 'iss', + 'sub', + 'nonce', + 'c_hash', + ] + + if (typeof expectedState === 'string') { + requiredClaims.push('s_hash') + } + + const { claims, header, key } = await validateJwt( + id_token, + checkSigningAlgorithm.bind( + undefined, + client.id_token_signed_response_alg, + as.id_token_signing_alg_values_supported, + ), + getPublicSigKeyFromIssuerJwksUri.bind(undefined, as, options), + getClockSkew(client), + getClockTolerance(client), + ) + .then(validatePresence.bind(undefined, requiredClaims)) + .then(validateIssuer.bind(undefined, as.issuer)) + .then(validateAudience.bind(undefined, client.client_id)) + + const clockSkew = getClockSkew(client) + const now = epochTime() + clockSkew + if (claims.iat! < now - 3600) { + throw new OPE('unexpected JWT "iat" (issued at) claim value, it is too far in the past') + } + + if ( + typeof claims.c_hash !== 'string' || + (await idTokenHashMatches(code, claims.c_hash, header.alg, key!)) !== true + ) { + throw new OPE('invalid ID Token "c_hash" (code hash) claim value') + } + + if (claims.s_hash !== undefined && typeof expectedState !== 'string') { + throw new OPE('could not verify ID Token "s_hash" (state hash) claim value') + } + + if ( + typeof expectedState === 'string' && + (typeof claims.s_hash !== 'string' || + (await idTokenHashMatches(expectedState, claims.s_hash, header.alg, key!)) !== true) + ) { + throw new OPE('invalid ID Token "s_hash" (state hash) claim value') + } + + if (client.require_auth_time !== undefined && typeof claims.auth_time !== 'number') { + throw new OPE('unexpected ID Token "auth_time" (authentication time) claim value') + } + + maxAge ??= client.default_max_age ?? skipAuthTimeCheck + if ( + (client.require_auth_time || maxAge !== skipAuthTimeCheck) && + claims.auth_time === undefined + ) { + throw new OPE('ID Token "auth_time" (authentication time) claim missing') + } + + if (maxAge !== skipAuthTimeCheck) { + if (typeof maxAge !== 'number' || maxAge < 0) { + throw new TypeError('"options.max_age" must be a non-negative number') + } + + const now = epochTime() + getClockSkew(client) + const tolerance = getClockTolerance(client) + if ((claims).auth_time! + maxAge < now - tolerance) { + throw new OPE('too much time has elapsed since the last End-User authentication') + } + } + + if (!validateString(expectedNonce)) { + throw new TypeError('"expectedNonce" must be a non-empty string') + } + if (claims.nonce !== expectedNonce) { + throw new OPE('unexpected ID Token "nonce" claim value') + } + + if (Array.isArray(claims.aud) && claims.aud.length !== 1 && claims.azp !== client.client_id) { + throw new OPE('unexpected ID Token "azp" (authorized party) claim value') + } + + return result +} + /** * If configured must be the configured one (client) if not configured must be signalled by the * issuer to be supported (issuer) if not signalled must be fallback