From 9c704d7d88726b5dc63c5a5946f466d0a05ff11e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:58:30 +0100 Subject: [PATCH] fix(core): include the RFC 9449 `ath` claim in DPoP proofs (closes #3184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createDpopHeader` built DPoP proofs with only `htu`/`htm`/`jti`/`iat` and never emitted the `ath` claim, implementing the pre-standard draft-fett-oauth-dpop-04. RFC 9449 §7 requires that a DPoP proof accompanying an access token to a protected resource MUST carry an `ath` claim (§4.2: base64url(SHA-256(access token))). Resource servers that enforce this reject ath-less proofs with 401, so downstream consumers of `@inrupt/solid-client-authn-browser` authenticate but then cannot read or write protected resources. (This is a known artefact of the pre-RFC DPoP era across the Solid ecosystem.) - `createDpopHeader` takes an optional `accessToken`; when present it adds the `ath` claim (base64url SHA-256 of the token) to the proof. Omitting it keeps the previous payload, so the public signature stays backward-compatible — the token-endpoint call sites (oidc-browser) correctly continue to omit `ath`. - `buildDpopFetchOptions` passes the `authToken` it already holds, so resource requests now carry `ath`. - Uses `jose`'s `base64url` + the Web Crypto `crypto.subtle` API that `jose` v6 itself runs on (cross-platform: browsers + the Node versions jose v6 targets); no new dependency and no platform-specific code path. - Tests: `ath` equals base64url(SHA-256(token)) when bound; absent otherwise. Co-Authored-By: Claude Opus 4.8 --- .../src/authenticatedFetch/dpopUtils.spec.ts | 33 ++++++++++++++++++- .../core/src/authenticatedFetch/dpopUtils.ts | 28 ++++++++++++++-- .../src/authenticatedFetch/fetchFactory.ts | 10 +++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/core/src/authenticatedFetch/dpopUtils.spec.ts b/packages/core/src/authenticatedFetch/dpopUtils.spec.ts index 0691aeb53d..015f60f4f3 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 f90a7dd45c..54e750aacf 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 5bb4ea3b59..16fbc7e994 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,