From 30931ba3cec58c6eaa6139734f1a399b34885a7c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 16 Sep 2024 11:58:21 +0200 Subject: [PATCH] feat: allow to modify issued JWT headers and payloads before signing --- docs/README.md | 2 + docs/interfaces/DPoPOptions.md | 12 ++ docs/interfaces/ModifyAssertionFunction.md | 20 +++ docs/interfaces/PrivateKey.md | 12 ++ docs/variables/modifyAssertion.md | 55 +++++++ src/index.ts | 165 +++++++++++++++------ tap/end2end-client-credentials.ts | 24 ++- tap/end2end-device-code.ts | 12 +- tap/request_object.ts | 24 +++ 9 files changed, 282 insertions(+), 44 deletions(-) create mode 100644 docs/interfaces/ModifyAssertionFunction.md create mode 100644 docs/variables/modifyAssertion.md diff --git a/docs/README.md b/docs/README.md index 30c67dc6..dcec9110 100644 --- a/docs/README.md +++ b/docs/README.md @@ -177,6 +177,7 @@ Support from the community to continue maintaining and improving this module is - [JWKS](interfaces/JWKS.md) - [JWKSCacheOptions](interfaces/JWKSCacheOptions.md) - [JWTAccessTokenClaims](interfaces/JWTAccessTokenClaims.md) +- [ModifyAssertionFunction](interfaces/ModifyAssertionFunction.md) - [MTLSEndpointAliases](interfaces/MTLSEndpointAliases.md) - [OAuth2Error](interfaces/OAuth2Error.md) - [OAuth2TokenEndpointResponse](interfaces/OAuth2TokenEndpointResponse.md) @@ -215,6 +216,7 @@ Support from the community to continue maintaining and improving this module is - [expectNoNonce](variables/expectNoNonce.md) - [expectNoState](variables/expectNoState.md) - [jwksCache](variables/jwksCache.md) +- [modifyAssertion](variables/modifyAssertion.md) - [skipAuthTimeCheck](variables/skipAuthTimeCheck.md) - [skipStateCheck](variables/skipStateCheck.md) - [skipSubjectCheck](variables/skipSubjectCheck.md) diff --git a/docs/interfaces/DPoPOptions.md b/docs/interfaces/DPoPOptions.md index 30bc02b1..d1e8bfdd 100644 --- a/docs/interfaces/DPoPOptions.md +++ b/docs/interfaces/DPoPOptions.md @@ -26,6 +26,18 @@ The public key corresponding to [DPoPOptions.privateKey](DPoPOptions.md#privatek *** +### \[modifyAssertion\]? + +• `optional` **\[modifyAssertion\]**: [`ModifyAssertionFunction`](ModifyAssertionFunction.md) + +Use to modify the DPoP Proof JWT right before it is signed. + +#### See + +[modifyAssertion](../variables/modifyAssertion.md) + +*** + ### nonce? • `optional` **nonce**: `string` diff --git a/docs/interfaces/ModifyAssertionFunction.md b/docs/interfaces/ModifyAssertionFunction.md new file mode 100644 index 00000000..e2a8649a --- /dev/null +++ b/docs/interfaces/ModifyAssertionFunction.md @@ -0,0 +1,20 @@ +# Interface: ModifyAssertionFunction() + +[💗 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). + +*** + +▸ **ModifyAssertionFunction**(`header`, `payload`): `void` + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `header` | [`Record`](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type)\<`string`, `undefined` \| [`JsonValue`](../type-aliases/JsonValue.md)\> | JWS Header to modify right before it is signed. | +| `payload` | [`Record`](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type)\<`string`, `undefined` \| [`JsonValue`](../type-aliases/JsonValue.md)\> | JWT Claims Set to modify right before it is signed. | + +## Returns + +`void` diff --git a/docs/interfaces/PrivateKey.md b/docs/interfaces/PrivateKey.md index dba1c7c2..6a995486 100644 --- a/docs/interfaces/PrivateKey.md +++ b/docs/interfaces/PrivateKey.md @@ -21,6 +21,18 @@ Its algorithm must be compatible with a supported [JWS `alg` Algorithm](../type- *** +### \[modifyAssertion\]? + +• `optional` **\[modifyAssertion\]**: [`ModifyAssertionFunction`](ModifyAssertionFunction.md) + +Use to modify the JWT signed by this key right before it is signed. + +#### See + +[modifyAssertion](../variables/modifyAssertion.md) + +*** + ### kid? • `optional` **kid**: `string` diff --git a/docs/variables/modifyAssertion.md b/docs/variables/modifyAssertion.md new file mode 100644 index 00000000..054aa510 --- /dev/null +++ b/docs/variables/modifyAssertion.md @@ -0,0 +1,55 @@ +# Variable: modifyAssertion + +[💗 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). + +*** + +• `const` **modifyAssertion**: unique `symbol` + +Use to mutate JWT header and payload before they are signed. Its intended use is working around +non-conform server behaviours, such as modifying JWT "aud" (audience) claims, or otherwise +changing fixed claims used by this library. + +## Examples + +Changing Private Key JWT client assertion audience issued from an array to a string + +```ts +import * as oauth from 'oauth4webapi' + +// Prerequisites +let as!: oauth.AuthorizationServer +let client!: oauth.Client +let parameters!: URLSearchParams +let clientPrivateKey!: CryptoKey + +const response = await oauth.pushedAuthorizationRequest(as, client, parameters, { + clientPrivateKey: { + key: clientPrivateKey, + [oauth.modifyAssertion](header, payload) { + payload.aud = as.issuer + }, + }, +}) +``` + +Changing Request Object issued by [issueRequestObject](../functions/issueRequestObject.md) to have an expiration of 5 minutes + +```ts +import * as oauth from 'oauth4webapi' + +// Prerequisites +let as!: oauth.AuthorizationServer +let client!: oauth.Client +let parameters!: URLSearchParams +let jarPrivateKey!: CryptoKey + +const request = await oauth.issueRequestObject(as, client, parameters, { + key: jarPrivateKey, + [oauth.modifyAssertion](header, payload) { + payload.exp = payload.iat + 300 + }, +}) +``` diff --git a/src/index.ts b/src/index.ts index da489ba3..a6e47244 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,19 @@ function looseInstanceOf(input: unknown, expected: Constructor) } } +export interface ModifyAssertionFunction { + ( + /** + * JWS Header to modify right before it is signed. + */ + header: Record, + /** + * JWT Claims Set to modify right before it is signed. + */ + payload: Record, + ): void +} + /** * Interface to pass an asymmetric private key and, optionally, its associated JWK Key ID to be * added as a `kid` JOSE Header Parameter. @@ -57,6 +70,13 @@ export interface PrivateKey { * ID) will be added to the JOSE Header. */ kid?: string + + /** + * Use to modify the JWT signed by this key right before it is signed. + * + * @see {@link modifyAssertion} + */ + [modifyAssertion]?: ModifyAssertionFunction } /** @@ -327,6 +347,57 @@ export const clockTolerance: unique symbol = Symbol() */ export const customFetch: unique symbol = Symbol() +/** + * Use to mutate JWT header and payload before they are signed. Its intended use is working around + * non-conform server behaviours, such as modifying JWT "aud" (audience) claims, or otherwise + * changing fixed claims used by this library. + * + * @example + * + * Changing Private Key JWT client assertion audience issued from an array to a string + * + * ```ts + * import * as oauth from 'oauth4webapi' + * + * // Prerequisites + * let as!: oauth.AuthorizationServer + * let client!: oauth.Client + * let parameters!: URLSearchParams + * let clientPrivateKey!: CryptoKey + * + * const response = await oauth.pushedAuthorizationRequest(as, client, parameters, { + * clientPrivateKey: { + * key: clientPrivateKey, + * [oauth.modifyAssertion](header, payload) { + * payload.aud = as.issuer + * }, + * }, + * }) + * ``` + * + * @example + * + * Changing Request Object issued by {@link issueRequestObject} to have an expiration of 5 minutes + * + * ```ts + * import * as oauth from 'oauth4webapi' + * + * // Prerequisites + * let as!: oauth.AuthorizationServer + * let client!: oauth.Client + * let parameters!: URLSearchParams + * let jarPrivateKey!: CryptoKey + * + * const request = await oauth.issueRequestObject(as, client, parameters, { + * key: jarPrivateKey, + * [oauth.modifyAssertion](header, payload) { + * payload.exp = payload.iat + 300 + * }, + * }) + * ``` + */ +export const modifyAssertion: unique symbol = Symbol() + /** * DANGER ZONE - This option has security implications that must be understood, assessed for * applicability, and accepted before use. It is critical that the JSON Web Key Set cache only be @@ -1283,6 +1354,7 @@ export async function calculatePKCECodeChallenge(codeVerifier: string): Promise< interface NormalizedKeyInput { key?: CryptoKey kid?: string + modifyAssertion?: ModifyAssertionFunction } function getKeyAndKid(input: CryptoKey | PrivateKey | undefined): NormalizedKeyInput { @@ -1298,7 +1370,11 @@ function getKeyAndKid(input: CryptoKey | PrivateKey | undefined): NormalizedKeyI throw new TypeError('"kid" must be a non-empty string') } - return { key: input.key, kid: input.kid } + return { + key: input.key, + kid: input.kid, + modifyAssertion: input[modifyAssertion], + } } export interface DPoPOptions extends CryptoKeyPair { @@ -1320,6 +1396,13 @@ export interface DPoPOptions extends CryptoKeyPair { * will be used automatically. */ nonce?: string + + /** + * Use to modify the DPoP Proof JWT right before it is signed. + * + * @see {@link modifyAssertion} + */ + [modifyAssertion]?: ModifyAssertionFunction } export interface DPoPRequestOptions { @@ -1464,7 +1547,7 @@ function clientAssertion(as: AuthorizationServer, client: Client) { const now = epochTime() + getClockSkew(client) return { jti: randomBytes(), - aud: [as.issuer, as.token_endpoint], + aud: [as.issuer, as.token_endpoint!], exp: now + 60, iat: now, nbf: now, @@ -1481,15 +1564,14 @@ async function privateKeyJwt( client: Client, key: CryptoKey, kid?: string, + modifyAssertion?: ModifyAssertionFunction, ) { - return jwt( - { - alg: keyToJws(key), - kid, - }, - clientAssertion(as, client), - key, - ) + const header = { alg: keyToJws(key), kid } + const payload = clientAssertion(as, client) + + modifyAssertion?.(header, payload) + + return jwt(header, payload, key) } function assertAs(as: AuthorizationServer): as is AuthorizationServer { @@ -1574,13 +1656,13 @@ async function clientAuthentication( '"options.clientPrivateKey" must be provided when "client.token_endpoint_auth_method" is "private_key_jwt"', ) } - const { key, kid } = getKeyAndKid(clientPrivateKey) + const { key, kid, modifyAssertion } = getKeyAndKid(clientPrivateKey) if (!isPrivateKey(key)) { throw new TypeError('"options.clientPrivateKey.key" must be a private CryptoKey') } 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 privateKeyJwt(as, client, key, kid)) + body.set('client_assertion', await privateKeyJwt(as, client, key, kid, modifyAssertion)) break } // @ts-expect-error @@ -1603,7 +1685,7 @@ async function clientAuthentication( */ async function jwt( header: CompactJWSHeaderParameters, - claimsSet: Record, + payload: Record, key: CryptoKey, ) { if (!key.usages.includes('sign')) { @@ -1611,7 +1693,7 @@ async function jwt( 'CryptoKey instances used for signing assertions must include "sign" in their "usages"', ) } - const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(claimsSet)))}` + 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}` } @@ -1640,7 +1722,7 @@ export async function issueRequestObject( parameters = new URLSearchParams(parameters) - const { key, kid } = getKeyAndKid(privateKey) + const { key, kid, modifyAssertion } = getKeyAndKid(privateKey) if (!isPrivateKey(key)) { throw new TypeError('"privateKey.key" must be a private CryptoKey') } @@ -1648,7 +1730,7 @@ export async function issueRequestObject( parameters.set('client_id', client.client_id) const now = epochTime() + getClockSkew(client) - const claims: Record = { + const claims: Record = { ...Object.fromEntries(parameters.entries()), jti: randomBytes(), aud: as.issuer, @@ -1708,15 +1790,15 @@ export async function issueRequestObject( } } - return jwt( - { - alg: keyToJws(key), - typ: 'oauth-authz-req+jwt', - kid, - }, - claims, - key, - ) + const header = { + alg: keyToJws(key), + typ: 'oauth-authz-req+jwt', + kid, + } + + modifyAssertion?.(header, claims) + + return jwt(header, claims, key) } /** @@ -1749,24 +1831,23 @@ async function dpopProofJwt( } const now = epochTime() + clockSkew - const proof = await jwt( - { - alg: keyToJws(privateKey), - typ: 'dpop+jwt', - jwk: await publicJwk(publicKey), - }, - { - iat: now, - jti: randomBytes(), - htm, - nonce, - htu: `${url.origin}${url.pathname}`, - ath: accessToken ? b64u(await crypto.subtle.digest('SHA-256', buf(accessToken))) : undefined, - }, - privateKey, - ) + const header = { + alg: keyToJws(privateKey), + typ: 'dpop+jwt', + jwk: await publicJwk(publicKey), + } + 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, + } + + options[modifyAssertion]?.(header, payload) - headers.set('dpop', proof) + headers.set('dpop', await jwt(header, payload, privateKey)) } let jwkCache: WeakMap diff --git a/tap/end2end-client-credentials.ts b/tap/end2end-client-credentials.ts index 6dec2eba..81cdf7f7 100644 --- a/tap/end2end-client-credentials.ts +++ b/tap/end2end-client-credentials.ts @@ -138,7 +138,29 @@ export default (QUnit: QUnit) => { t.equal(token_type, dpop ? 'dpop' : 'bearer') { - let response = await lib.introspectionRequest(as, client, access_token, authenticated) + let response = await lib.introspectionRequest(as, client, access_token, { + clientPrivateKey: authenticated.clientPrivateKey + ? { + ...clientPrivateKey, + [lib.modifyAssertion](h, p) { + t.equal(h.alg, 'ES256') + p.foo = 'bar' + }, + } + : undefined, + async [lib.customFetch](...params: Parameters) { + if (authMethod === 'private_key_jwt') { + if (params[1]?.body instanceof URLSearchParams) { + t.propContains(await jose.decodeJwt(params[1].body.get('client_assertion')!), { + foo: 'bar', + }) + } else { + throw new Error() + } + } + return fetch(...params) + }, + }) const clone = await response.clone().text() if (jwtIntrospection) { diff --git a/tap/end2end-device-code.ts b/tap/end2end-device-code.ts index 552f6c6f..0d54b252 100644 --- a/tap/end2end-device-code.ts +++ b/tap/end2end-device-code.ts @@ -105,7 +105,15 @@ export default (QUnit: QUnit) => { undefined, undefined, { - DPoP, + DPoP: DPoP + ? { + ...DPoP, + [lib.modifyAssertion](h, p) { + t.equal(h.alg, 'ES256') + p.foo = 'bar' + }, + } + : undefined, async [lib.customFetch](...params: Parameters) { const url = new URL(params[0]) const { headers, method } = params[1]! @@ -124,6 +132,8 @@ export default (QUnit: QUnit) => { jwtAccessToken.cnf!.jkt, await jose.calculateJwkThumbprint(await jose.exportJWK(DPoP.publicKey)), ) + + t.propContains(await jose.decodeJwt(request.headers.get('dpop')!), { foo: 'bar' }) } else { t.equal(jwtAccessToken.cnf, undefined) } diff --git a/tap/request_object.ts b/tap/request_object.ts index ae22e8ea..828e1a8a 100644 --- a/tap/request_object.ts +++ b/tap/request_object.ts @@ -58,6 +58,30 @@ export default (QUnit: QUnit) => { t.propEqual(resource, ['urn:example:resource', 'urn:example:resource-2']) }) + test('issueRequestObject() with customization', async (t) => { + const kp = await keys.ES256 + + const jwt = await lib.issueRequestObject( + issuer, + client, + { + foo: 'bar', + }, + { + key: kp.privateKey, + [lib.modifyAssertion](h, p) { + t.equal(h.alg, 'ES256') + delete h.typ + p.foo = 'baz' + }, + }, + ) + + const { protectedHeader, payload } = await jose.jwtVerify(jwt, kp.publicKey) + t.propEqual(protectedHeader, { alg: 'ES256' }) + t.propContains(payload, { foo: 'baz' }) + }) + test('issueRequestObject() claims parameter', async (t) => { const kp = await keys.ES256