From 0916de23c6daf93434592b1c181b27b4ed13a277 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 14 Sep 2024 22:58:23 +0200 Subject: [PATCH] feat: add non-repudiation signature validation methods --- .github/workflows/conformance.yml | 3 + conformance/fapi/invalid-signature.ts | 17 +- conformance/runner.ts | 11 ++ docs/README.md | 8 +- .../validateDetachedSignatureResponse.md | 2 +- docs/functions/validateIdTokenSignature.md | 38 ++++ docs/functions/validateJwtAuthResponse.md | 2 +- .../validateJwtIntrospectionSignature.md | 37 ++++ .../functions/validateJwtUserinfoSignature.md | 37 ++++ ...alidateDetachedSignatureResponseOptions.md | 59 ------ ...Options.md => ValidateSignatureOptions.md} | 2 +- src/index.ts | 184 ++++++++++++++++-- tap/end2end-client-credentials.ts | 4 + tap/end2end.ts | 5 + 14 files changed, 328 insertions(+), 81 deletions(-) create mode 100644 docs/functions/validateIdTokenSignature.md create mode 100644 docs/functions/validateJwtIntrospectionSignature.md create mode 100644 docs/functions/validateJwtUserinfoSignature.md delete mode 100644 docs/interfaces/ValidateDetachedSignatureResponseOptions.md rename docs/interfaces/{ValidateJwtAuthResponseOptions.md => ValidateSignatureOptions.md} (97%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e5d20436..0e8caa47 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -59,6 +59,9 @@ jobs: # FAPI 2.0 Message Signing ID1 - plan: fapi2-message-signing-id1-client-test-plan + - plan: fapi2-message-signing-id1-client-test-plan + variant: + fapi_client_type: 'plain_oauth' steps: - name: Checkout diff --git a/conformance/fapi/invalid-signature.ts b/conformance/fapi/invalid-signature.ts index 0132d762..845efd25 100644 --- a/conformance/fapi/invalid-signature.ts +++ b/conformance/fapi/invalid-signature.ts @@ -1,5 +1,18 @@ -import { test, skippable, flow, modules } from '../runner.js' +import { + test, + skippable, + rejects, + flow, + modules, + nonRepudiation, + plan, + variant, +} from '../runner.js' for (const module of modules('invalid-signature')) { - test.serial(skippable(flow()), module) + if (nonRepudiation(plan, variant)) { + test.serial(rejects(flow()), module, 'JWT signature verification failed') + } else { + test.serial(skippable(flow()), module) + } } diff --git a/conformance/runner.ts b/conformance/runner.ts index 82fa281d..b9034a04 100644 --- a/conformance/runner.ts +++ b/conformance/runner.ts @@ -90,6 +90,13 @@ function usesPar(plan: Plan) { return plan.name.startsWith('fapi2') || variant.fapi_auth_request_method === 'pushed' } +export function nonRepudiation(plan: Plan, variant: Record) { + return ( + variant.fapi_client_type === 'oidc' && + (plan.name.startsWith('fapi2-message-signing') || plan.name.startsWith('fapi1')) + ) +} + function usesRequestObject(planName: string, variant: Record) { if (planName.startsWith('fapi1')) { return true @@ -480,6 +487,10 @@ export const flow = (options?: MacroOptions) => { } } + if (nonRepudiation(plan, variant)) { + await oauth.validateIdTokenSignature(as, result) + } + t.log('token endpoint response body', { ...result }) ;({ access_token } = result) if (result.id_token) { diff --git a/docs/README.md b/docs/README.md index 96c1b0b8..d20a7545 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,9 @@ Support from the community to continue maintaining and improving this module is - [processUserInfoResponse](functions/processUserInfoResponse.md) - [userInfoRequest](functions/userInfoRequest.md) - [validateAuthResponse](functions/validateAuthResponse.md) +- [validateIdTokenSignature](functions/validateIdTokenSignature.md) - [validateJwtAuthResponse](functions/validateJwtAuthResponse.md) +- [validateJwtUserinfoSignature](functions/validateJwtUserinfoSignature.md) ## Authorization Server Metadata @@ -65,6 +67,7 @@ Support from the community to continue maintaining and improving this module is ## FAPI 1.0 Advanced - [validateDetachedSignatureResponse](functions/validateDetachedSignatureResponse.md) +- [validateIdTokenSignature](functions/validateIdTokenSignature.md) ## JWT Access Tokens @@ -87,6 +90,7 @@ Support from the community to continue maintaining and improving this module is - [processUserInfoResponse](functions/processUserInfoResponse.md) - [userInfoRequest](functions/userInfoRequest.md) +- [validateJwtUserinfoSignature](functions/validateJwtUserinfoSignature.md) ## Proof Key for Code Exchange (PKCE) @@ -113,6 +117,7 @@ Support from the community to continue maintaining and improving this module is - [isOAuth2Error](functions/isOAuth2Error.md) - [parseWwwAuthenticateChallenges](functions/parseWwwAuthenticateChallenges.md) - [processIntrospectionResponse](functions/processIntrospectionResponse.md) +- [validateJwtIntrospectionSignature](functions/validateJwtIntrospectionSignature.md) ## Token Revocation @@ -169,9 +174,8 @@ Support from the community to continue maintaining and improving this module is - [UserInfoAddress](interfaces/UserInfoAddress.md) - [UserInfoRequestOptions](interfaces/UserInfoRequestOptions.md) - [UserInfoResponse](interfaces/UserInfoResponse.md) -- [ValidateDetachedSignatureResponseOptions](interfaces/ValidateDetachedSignatureResponseOptions.md) - [ValidateJWTAccessTokenOptions](interfaces/ValidateJWTAccessTokenOptions.md) -- [ValidateJwtAuthResponseOptions](interfaces/ValidateJwtAuthResponseOptions.md) +- [ValidateSignatureOptions](interfaces/ValidateSignatureOptions.md) - [WWWAuthenticateChallenge](interfaces/WWWAuthenticateChallenge.md) - [WWWAuthenticateChallengeParameters](interfaces/WWWAuthenticateChallengeParameters.md) diff --git a/docs/functions/validateDetachedSignatureResponse.md b/docs/functions/validateDetachedSignatureResponse.md index beced35a..f4af495f 100644 --- a/docs/functions/validateDetachedSignatureResponse.md +++ b/docs/functions/validateDetachedSignatureResponse.md @@ -21,7 +21,7 @@ responses. | `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`? | [`ValidateDetachedSignatureResponseOptions`](../interfaces/ValidateDetachedSignatureResponseOptions.md) | - | +| `options`? | [`ValidateSignatureOptions`](../interfaces/ValidateSignatureOptions.md) | - | ## Returns diff --git a/docs/functions/validateIdTokenSignature.md b/docs/functions/validateIdTokenSignature.md new file mode 100644 index 00000000..becba29f --- /dev/null +++ b/docs/functions/validateIdTokenSignature.md @@ -0,0 +1,38 @@ +# Function: validateIdTokenSignature() + +[💗 Help the project](https://github.com/sponsors/panva) + +Support from the community to continue maintaining and improving this module is welcome. If you find the module useful, please consider supporting the project by [becoming a sponsor](https://github.com/sponsors/panva). + +*** + +▸ **validateIdTokenSignature**(`as`, `ref`, `options`?): [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Validates the JWS Signature of an ID Token included in results previously resolved from +[processAuthorizationCodeOpenIDResponse](processAuthorizationCodeOpenIDResponse.md), [processRefreshTokenResponse](processRefreshTokenResponse.md), or +[processDeviceCodeResponse](processDeviceCodeResponse.md) for non-repudiation purposes. + +Note: Validating signatures of ID Tokens received via direct communication between the Client and +the Token Endpoint (which it is here) is not mandatory since the TLS server validation is used to +validate the issuer instead of checking the token signature. You only need to use this method for +non-repudiation purposes. + +Note: Supports only digital signatures. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `as` | [`AuthorizationServer`](../interfaces/AuthorizationServer.md) | Authorization Server Metadata. | +| `ref` | [`TokenEndpointResponse`](../interfaces/TokenEndpointResponse.md) \| [`OpenIDTokenEndpointResponse`](../interfaces/OpenIDTokenEndpointResponse.md) | Value previously resolved from [processAuthorizationCodeOpenIDResponse](processAuthorizationCodeOpenIDResponse.md), [processRefreshTokenResponse](processRefreshTokenResponse.md), or [processDeviceCodeResponse](processDeviceCodeResponse.md). | +| `options`? | [`ValidateSignatureOptions`](../interfaces/ValidateSignatureOptions.md) | - | + +## Returns + +[`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Resolves if the signature validates, rejects otherwise. + +## See + +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) diff --git a/docs/functions/validateJwtAuthResponse.md b/docs/functions/validateJwtAuthResponse.md index 9708b856..e22727d6 100644 --- a/docs/functions/validateJwtAuthResponse.md +++ b/docs/functions/validateJwtAuthResponse.md @@ -18,7 +18,7 @@ Same as [validateAuthResponse](validateAuthResponse.md) but for signed JARM resp | `client` | [`Client`](../interfaces/Client.md) | Client Metadata. | | `parameters` | [`URLSearchParams`](https://developer.mozilla.org/docs/Web/API/URLSearchParams) \| [`URL`](https://developer.mozilla.org/docs/Web/API/URL) | JARM authorization response. | | `expectedState`? | `string` \| *typeof* [`expectNoState`](../variables/expectNoState.md) \| *typeof* [`skipStateCheck`](../variables/skipStateCheck.md) | Expected `state` parameter value. Default is [expectNoState](../variables/expectNoState.md). | -| `options`? | [`ValidateJwtAuthResponseOptions`](../interfaces/ValidateJwtAuthResponseOptions.md) | - | +| `options`? | [`ValidateSignatureOptions`](../interfaces/ValidateSignatureOptions.md) | - | ## Returns diff --git a/docs/functions/validateJwtIntrospectionSignature.md b/docs/functions/validateJwtIntrospectionSignature.md new file mode 100644 index 00000000..8cdb7934 --- /dev/null +++ b/docs/functions/validateJwtIntrospectionSignature.md @@ -0,0 +1,37 @@ +# Function: validateJwtIntrospectionSignature() + +[💗 Help the project](https://github.com/sponsors/panva) + +Support from the community to continue maintaining and improving this module is welcome. If you find the module useful, please consider supporting the project by [becoming a sponsor](https://github.com/sponsors/panva). + +*** + +▸ **validateJwtIntrospectionSignature**(`as`, `ref`, `options`?): [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Validates the JWS Signature of an JWT [Response](https://developer.mozilla.org/docs/Web/API/Response) body of responses previously processed by +[processIntrospectionResponse](processIntrospectionResponse.md) for non-repudiation purposes. + +Note: Validating signatures of JWTs received via direct communication between the Client and a +TLS-secured Endpoint (which it is here) is not mandatory since the TLS server validation is used +to validate the issuer instead of checking the token signature. You only need to use this method +for non-repudiation purposes. + +Note: Supports only digital signatures. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `as` | [`AuthorizationServer`](../interfaces/AuthorizationServer.md) | Authorization Server Metadata. | +| `ref` | [`Response`](https://developer.mozilla.org/docs/Web/API/Response) | Response previously processed by [processIntrospectionResponse](processIntrospectionResponse.md). | +| `options`? | [`ValidateSignatureOptions`](../interfaces/ValidateSignatureOptions.md) | - | + +## Returns + +[`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Resolves if the signature validates, rejects otherwise. + +## See + +[draft-ietf-oauth-jwt-introspection-response-12 - JWT Response for OAuth Token Introspection](https://www.ietf.org/archive/id/draft-ietf-oauth-jwt-introspection-response-12.html#section-5) diff --git a/docs/functions/validateJwtUserinfoSignature.md b/docs/functions/validateJwtUserinfoSignature.md new file mode 100644 index 00000000..2ccc0008 --- /dev/null +++ b/docs/functions/validateJwtUserinfoSignature.md @@ -0,0 +1,37 @@ +# Function: validateJwtUserinfoSignature() + +[💗 Help the project](https://github.com/sponsors/panva) + +Support from the community to continue maintaining and improving this module is welcome. If you find the module useful, please consider supporting the project by [becoming a sponsor](https://github.com/sponsors/panva). + +*** + +▸ **validateJwtUserinfoSignature**(`as`, `ref`, `options`?): [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Validates the JWS Signature of a JWT [Response](https://developer.mozilla.org/docs/Web/API/Response) body of response previously processed by +[processUserInfoResponse](processUserInfoResponse.md) for non-repudiation purposes. + +Note: Validating signatures of JWTs received via direct communication between the Client and a +TLS-secured Endpoint (which it is here) is not mandatory since the TLS server validation is used +to validate the issuer instead of checking the token signature. You only need to use this method +for non-repudiation purposes. + +Note: Supports only digital signatures. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `as` | [`AuthorizationServer`](../interfaces/AuthorizationServer.md) | Authorization Server Metadata. | +| `ref` | [`Response`](https://developer.mozilla.org/docs/Web/API/Response) | Response previously processed by [processUserInfoResponse](processUserInfoResponse.md). | +| `options`? | [`ValidateSignatureOptions`](../interfaces/ValidateSignatureOptions.md) | - | + +## Returns + +[`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<`void`\> + +Resolves if the signature validates, rejects otherwise. + +## See + +[OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) diff --git a/docs/interfaces/ValidateDetachedSignatureResponseOptions.md b/docs/interfaces/ValidateDetachedSignatureResponseOptions.md deleted file mode 100644 index 1efdcf35..00000000 --- a/docs/interfaces/ValidateDetachedSignatureResponseOptions.md +++ /dev/null @@ -1,59 +0,0 @@ -# Interface: ValidateDetachedSignatureResponseOptions - -[💗 Help the project](https://github.com/sponsors/panva) - -Support from the community to continue maintaining and improving this module is welcome. If you find the module useful, please consider supporting the project by [becoming a sponsor](https://github.com/sponsors/panva). - -*** - -## Properties - -### \[customFetch\]()? - -• `optional` **\[customFetch\]**: (`input`, `init`?) => [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[`Response`](https://developer.mozilla.org/docs/Web/API/Response)\> - -See [customFetch](../variables/customFetch.md). - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `input` | `RequestInfo` \| [`URL`](https://developer.mozilla.org/docs/Web/API/URL) | -| `init`? | `RequestInit` | - -#### Returns - -[`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[`Response`](https://developer.mozilla.org/docs/Web/API/Response)\> - -*** - -### \[jwksCache\]? - -• `optional` **\[jwksCache\]**: [`JWKSCacheInput`](../type-aliases/JWKSCacheInput.md) - -See [jwksCache](../variables/jwksCache.md). - -*** - -### headers? - -• `optional` **headers**: [`Record`](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type)\<`string`, `string`\> \| [`string`, `string`][] \| [`Headers`](https://developer.mozilla.org/docs/Web/API/Headers) - -Headers to additionally send with the HTTP request(s) triggered by this function's invocation. - -*** - -### signal? - -• `optional` **signal**: [`AbortSignal`](https://developer.mozilla.org/docs/Web/API/AbortSignal) \| () => [`AbortSignal`](https://developer.mozilla.org/docs/Web/API/AbortSignal) - -An AbortSignal instance, or a factory returning one, to abort the HTTP request(s) triggered by -this function's invocation. - -#### Example - -A 5000ms timeout AbortSignal for every request - -```js -const signal = () => AbortSignal.timeout(5_000) // Note: AbortSignal.timeout may not yet be available in all runtimes. -``` diff --git a/docs/interfaces/ValidateJwtAuthResponseOptions.md b/docs/interfaces/ValidateSignatureOptions.md similarity index 97% rename from docs/interfaces/ValidateJwtAuthResponseOptions.md rename to docs/interfaces/ValidateSignatureOptions.md index abcb2f9d..2be2e38b 100644 --- a/docs/interfaces/ValidateJwtAuthResponseOptions.md +++ b/docs/interfaces/ValidateSignatureOptions.md @@ -1,4 +1,4 @@ -# Interface: ValidateJwtAuthResponseOptions +# Interface: ValidateSignatureOptions [💗 Help the project](https://github.com/sponsors/panva) diff --git a/src/index.ts b/src/index.ts index cfd50fb4..d9222f3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2448,8 +2448,9 @@ export async function processUserInfoResponse( let json: JsonValue if (getContentType(response) === 'application/jwt') { assertReadableResponse(response) + const jwt = await response.text() const { claims } = await validateJwt( - await response.text(), + jwt, checkSigningAlgorithm.bind( undefined, client.userinfo_signed_response_alg, @@ -2462,6 +2463,7 @@ export async function processUserInfoResponse( .then(validateOptionalAudience.bind(undefined, client.client_id)) .then(validateOptionalIssuer.bind(undefined, as.issuer)) + jwtResponseBodies.set(response, jwt) json = claims } else { if (client.userinfo_signed_response_alg) { @@ -2583,6 +2585,7 @@ export async function refreshTokenGrantRequest( } const idTokenClaims = new WeakMap() +const jwtResponseBodies = new WeakMap() /** * Returns ID Token claims validated during {@link processAuthorizationCodeOpenIDResponse}. @@ -2622,6 +2625,140 @@ export function getValidatedIdTokenClaims( return claims } +export interface ValidateSignatureOptions extends HttpRequestOptions, JWKSCacheOptions {} + +/** + * Validates the JWS Signature of an ID Token included in results previously resolved from + * {@link processAuthorizationCodeOpenIDResponse}, {@link processRefreshTokenResponse}, or + * {@link processDeviceCodeResponse} for non-repudiation purposes. + * + * Note: Validating signatures of ID Tokens received via direct communication between the Client and + * the Token Endpoint (which it is here) is not mandatory since the TLS server validation is used to + * validate the issuer instead of checking the token signature. You only need to use this method for + * non-repudiation purposes. + * + * Note: Supports only digital signatures. + * + * @param as Authorization Server Metadata. + * @param ref Value previously resolved from {@link processAuthorizationCodeOpenIDResponse}, + * {@link processRefreshTokenResponse}, or {@link processDeviceCodeResponse}. + * + * @returns Resolves if the signature validates, rejects otherwise. + * + * @group Authorization Code Grant w/ OpenID Connect (OIDC) + * @group FAPI 1.0 Advanced + * + * @see [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) + */ +export async function validateIdTokenSignature( + as: AuthorizationServer, + ref: OpenIDTokenEndpointResponse | TokenEndpointResponse, + options?: ValidateSignatureOptions, +): Promise { + assertAs(as) + + if (!getValidatedIdTokenClaims(ref)) { + throw new OPE('"ref" does not contain an ID Token to verify the signature of') + } + + const { 0: protectedHeader, 1: payload, 2: encodedSignature } = ref.id_token!.split('.') + + const header: CompactJWSHeaderParameters = JSON.parse(buf(b64u(protectedHeader))) + + if (header.alg.startsWith('HS')) { + throw new UnsupportedOperationError() + } + + let key!: CryptoKey + key = await getPublicSigKeyFromIssuerJwksUri(as, options, header) + await validateJwsSignature(protectedHeader, payload, key, b64u(encodedSignature)) +} + +async function validateJwtResponseSignature( + as: AuthorizationServer, + ref: Response, + options?: ValidateSignatureOptions, +): Promise { + assertAs(as) + + if (!jwtResponseBodies.has(ref)) { + throw new OPE('"ref" does not contain a processed JWT Response to verify the signature of') + } + + const { + 0: protectedHeader, + 1: payload, + 2: encodedSignature, + } = jwtResponseBodies.get(ref)!.split('.') + + const header: CompactJWSHeaderParameters = JSON.parse(buf(b64u(protectedHeader))) + + if (header.alg.startsWith('HS')) { + throw new UnsupportedOperationError() + } + + let key!: CryptoKey + key = await getPublicSigKeyFromIssuerJwksUri(as, options, header) + await validateJwsSignature(protectedHeader, payload, key, b64u(encodedSignature)) +} + +/** + * Validates the JWS Signature of a JWT {@link !Response} body of response previously processed by + * {@link processUserInfoResponse} for non-repudiation purposes. + * + * Note: Validating signatures of JWTs received via direct communication between the Client and a + * TLS-secured Endpoint (which it is here) is not mandatory since the TLS server validation is used + * to validate the issuer instead of checking the token signature. You only need to use this method + * for non-repudiation purposes. + * + * Note: Supports only digital signatures. + * + * @param as Authorization Server Metadata. + * @param ref Response previously processed by {@link processUserInfoResponse}. + * + * @returns Resolves if the signature validates, rejects otherwise. + * + * @group Authorization Code Grant w/ OpenID Connect (OIDC) + * @group OpenID Connect (OIDC) UserInfo + * + * @see [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) + */ +export function validateJwtUserinfoSignature( + as: AuthorizationServer, + ref: Response, + options?: ValidateSignatureOptions, +): Promise { + return validateJwtResponseSignature(as, ref, options) +} + +/** + * Validates the JWS Signature of an JWT {@link !Response} body of responses previously processed by + * {@link processIntrospectionResponse} for non-repudiation purposes. + * + * Note: Validating signatures of JWTs received via direct communication between the Client and a + * TLS-secured Endpoint (which it is here) is not mandatory since the TLS server validation is used + * to validate the issuer instead of checking the token signature. You only need to use this method + * for non-repudiation purposes. + * + * Note: Supports only digital signatures. + * + * @param as Authorization Server Metadata. + * @param ref Response previously processed by {@link processIntrospectionResponse}. + * + * @returns Resolves if the signature validates, rejects otherwise. + * + * @group Token Introspection + * + * @see [draft-ietf-oauth-jwt-introspection-response-12 - JWT Response for OAuth Token Introspection](https://www.ietf.org/archive/id/draft-ietf-oauth-jwt-introspection-response-12.html#section-5) + */ +export function validateJwtIntrospectionSignature( + as: AuthorizationServer, + ref: Response, + options?: ValidateSignatureOptions, +): Promise { + return validateJwtResponseSignature(as, ref, options) +} + async function processGenericAccessTokenResponse( as: AuthorizationServer, client: Client, @@ -3414,8 +3551,9 @@ export async function processIntrospectionResponse( let json: JsonValue if (getContentType(response) === 'application/token-introspection+jwt') { assertReadableResponse(response) + const jwt = await response.text() const { claims } = await validateJwt( - await response.text(), + jwt, checkSigningAlgorithm.bind( undefined, client.introspection_signed_response_alg, @@ -3430,6 +3568,7 @@ export async function processIntrospectionResponse( .then(validateIssuer.bind(undefined, as.issuer)) .then(validateAudience.bind(undefined, client.client_id)) + jwtResponseBodies.set(response, jwt) json = claims.token_introspection if (!isJsonObject(json)) { throw new OPE('JWT "token_introspection" claim must be a JSON object') @@ -3594,6 +3733,19 @@ function keyToSubtle(key: CryptoKey): AlgorithmIdentifier | RsaPssParams | Ecdsa const noSignatureCheck = Symbol() +async function validateJwsSignature( + protectedHeader: string, + payload: string, + key: CryptoKey, + signature: Uint8Array, +) { + const input = `${protectedHeader}.${payload}` + const verified = await crypto.subtle.verify(keyToSubtle(key), key, signature, buf(input)) + if (!verified) { + throw new OPE('JWT signature verification failed') + } +} + /** * Minimal JWT validation implementation. */ @@ -3632,11 +3784,7 @@ async function validateJwt( let key!: CryptoKey if (getKey !== noSignatureCheck) { key = await getKey(header) - const input = `${protectedHeader}.${payload}` - const verified = await crypto.subtle.verify(keyToSubtle(key), key, signature, buf(input)) - if (!verified) { - throw new OPE('JWT signature verification failed') - } + await validateJwsSignature(protectedHeader, payload, key, signature) } let claims: JsonValue @@ -3692,8 +3840,6 @@ async function validateJwt( return { header, claims, signature, key } } -export interface ValidateJwtAuthResponseOptions extends HttpRequestOptions, JWKSCacheOptions {} - /** * Same as {@link validateAuthResponse} but for signed JARM responses. * @@ -3715,7 +3861,7 @@ export async function validateJwtAuthResponse( client: Client, parameters: URLSearchParams | URL, expectedState?: string | typeof expectNoState | typeof skipStateCheck, - options?: ValidateJwtAuthResponseOptions, + options?: ValidateSignatureOptions, ): Promise { assertAs(as) assertClient(client) @@ -3796,10 +3942,6 @@ async function idTokenHashMatches(data: string, actual: string, alg: JWSAlgorith return actual === expected } -export interface ValidateDetachedSignatureResponseOptions - extends HttpRequestOptions, - JWKSCacheOptions {} - /** * Same as {@link validateAuthResponse} but for FAPI 1.0 Advanced Detached Signature authorization * responses. @@ -3829,7 +3971,7 @@ export async function validateDetachedSignatureResponse( expectedNonce: string, expectedState?: string | typeof expectNoState, maxAge?: number | typeof skipAuthTimeCheck, - options?: ValidateDetachedSignatureResponseOptions, + options?: ValidateSignatureOptions, ): Promise { assertAs(as) assertClient(client) @@ -4741,3 +4883,15 @@ export const experimental_validateJwtAccessToken = ( * @deprecated Use {@link jwksCache}. */ export const experimental_jwksCache = jwksCache +/** + * @ignore + * + * @deprecated Use {@link ValidateSignatureOptions}. + */ +export interface ValidateJwtResponseSignatureOptions extends ValidateSignatureOptions {} +/** + * @ignore + * + * @deprecated Use {@link ValidateSignatureOptions}. + */ +export interface ValidateDetachedSignatureResponseOptions extends ValidateSignatureOptions {} diff --git a/tap/end2end-client-credentials.ts b/tap/end2end-client-credentials.ts index 8e677bc2..6c76f20c 100644 --- a/tap/end2end-client-credentials.ts +++ b/tap/end2end-client-credentials.ts @@ -139,6 +139,10 @@ export default (QUnit: QUnit) => { if (!assertNotOAuth2Error(result)) return + if (jwtIntrospection) { + await lib.validateJwtIntrospectionSignature(as, response) + } + t.propContains(result, { active: true, scope: 'api:write', diff --git a/tap/end2end.ts b/tap/end2end.ts index 5a3e3ec9..c06a61f5 100644 --- a/tap/end2end.ts +++ b/tap/end2end.ts @@ -245,6 +245,7 @@ export default (QUnit: QUnit) => { throw new Error() } const { sub } = lib.getValidatedIdTokenClaims(result) + await lib.validateIdTokenSignature(as, result) { const userInfoRequest = () => lib.userInfoRequest(as, client, access_token, { DPoP }) @@ -267,6 +268,10 @@ export default (QUnit: QUnit) => { } await lib.processUserInfoResponse(as, client, sub, response) + + if (jwtUserinfo) { + await lib.validateJwtUserinfoSignature(as, response) + } } {