diff --git a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts index 0691aeb53..015f60f4f 100644 --- a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts +++ b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts @@ -20,7 +20,7 @@ import { it, describe, expect } from "@jest/globals"; import type { CryptoKey } from "jose"; -import { generateKeyPair, exportJWK, jwtVerify } from "jose"; +import { generateKeyPair, exportJWK, jwtVerify, base64url } from "jose"; import { createDpopHeader, generateDpopKeyPair } from "./dpopUtils"; let publicKey: CryptoKey | undefined; @@ -93,6 +93,37 @@ describe("createDpopHeader", () => { expect(protectedHeader.typ).toBe("dpop+jwt"); expect(protectedHeader.jwk).toEqual((await mockKeyPair()).publicKey); }); + + it("omits the 'ath' claim when no access token is provided (backwards compatible)", async () => { + const header = await createDpopHeader( + "https://some.resource", + "GET", + await mockKeyPair(), + ); + const { payload } = await jwtVerify(header, (await mockJwk()).publicKey); + expect(payload.ath).toBeUndefined(); + }); + + it("binds the proof to the access token via the RFC 9449 'ath' claim when provided", async () => { + const accessToken = "some.access.token"; + const header = await createDpopHeader( + "https://some.resource", + "GET", + await mockKeyPair(), + accessToken, + ); + const { payload } = await jwtVerify(header, (await mockJwk()).publicKey); + // RFC 9449 §4.2: ath = base64url(SHA-256(ASCII(access token))). + const expectedAth = base64url.encode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ), + ), + ); + expect(payload.ath).toBe(expectedAth); + }); }); describe("generateDpopKeyPair", () => { diff --git a/packages/core/src/authenticatedFetch/dpopUtils.ts b/packages/core/src/authenticatedFetch/dpopUtils.ts index f90a7dd45..54e750aac 100644 --- a/packages/core/src/authenticatedFetch/dpopUtils.ts +++ b/packages/core/src/authenticatedFetch/dpopUtils.ts @@ -19,7 +19,7 @@ // import type { JWK, CryptoKey } from "jose"; -import { SignJWT, generateKeyPair, exportJWK } from "jose"; +import { SignJWT, generateKeyPair, exportJWK, base64url } from "jose"; import { v4 } from "uuid"; import { PREFERRED_SIGNING_ALG } from "../constant"; @@ -41,23 +41,47 @@ export type KeyPair = { }; /** - * Creates a DPoP header according to https://tools.ietf.org/html/draft-fett-oauth-dpop-04, + * Computes the RFC 9449 `ath` claim: the base64url-encoded SHA-256 hash of the + * ASCII encoding of the access token's value (RFC 9449 §4.2). Uses the WHATWG + * Web Crypto API (`crypto.subtle`) — the same cross-platform primitive `jose` + * relies on, available in browsers and the Node versions `jose` v6 supports. + * + * @hidden + */ +async function accessTokenHash(accessToken: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ); + return base64url.encode(new Uint8Array(digest)); +} + +/** + * Creates a DPoP header according to {@link https://www.rfc-editor.org/rfc/rfc9449 RFC 9449}, * based on the target URL and method, using the provided key. * * @param audience Target URL. * @param method HTTP method allowed. * @param dpopKey Key used to sign the token. + * @param accessToken When provided, the proof is bound to this access token via the + * RFC 9449 `ath` claim. This is REQUIRED (RFC 9449 §7) whenever the DPoP proof + * accompanies an access token to a protected resource — without it, a correctly + * enforcing resource server rejects the request with 401. * @returns A JWT that can be used as a DPoP Authorization header. */ export async function createDpopHeader( audience: string, method: string, dpopKey: KeyPair, + accessToken?: string, ): Promise { return new SignJWT({ htu: normalizeHTU(audience), htm: method.toUpperCase(), jti: v4(), + ...(accessToken !== undefined + ? { ath: await accessTokenHash(accessToken) } + : {}), }) .setProtectedHeader({ alg: PREFERRED_SIGNING_ALG[0], diff --git a/packages/core/src/authenticatedFetch/fetchFactory.ts b/packages/core/src/authenticatedFetch/fetchFactory.ts index 5bb4ea3b5..16fbc7e99 100644 --- a/packages/core/src/authenticatedFetch/fetchFactory.ts +++ b/packages/core/src/authenticatedFetch/fetchFactory.ts @@ -60,9 +60,17 @@ async function buildDpopFetchOptions( const headers = new Headers(defaultOptions?.headers); // Any pre-existing Authorization header should be overriden. headers.set("Authorization", `DPoP ${authToken}`); + // Bind the proof to this access token via the RFC 9449 `ath` claim (§7): a proof + // accompanying an access token to a protected resource MUST carry `ath`, or a + // correctly enforcing resource server rejects it with 401. headers.set( "DPoP", - await createDpopHeader(targetUrl, defaultOptions?.method ?? "get", dpopKey), + await createDpopHeader( + targetUrl, + defaultOptions?.method ?? "get", + dpopKey, + authToken, + ), ); return { ...defaultOptions,