fix(core): include the RFC 9449 ath claim in DPoP proofs (closes #3184)#4292
fix(core): include the RFC 9449 ath claim in DPoP proofs (closes #3184)#4292jeswr wants to merge 1 commit into
Conversation
…rupt#3184) `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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR updates DPoP proof generation to bind proofs to the presented access token via the RFC 9449 ath claim, and updates the authenticated fetch flow + tests accordingly.
Changes:
- Add access-token binding (
ath) support tocreateDpopHeaderand compute it via SHA-256 + base64url. - Update the fetch factory to pass the access token into DPoP proof creation.
- Add Jest coverage for presence/absence of the
athclaim.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/core/src/authenticatedFetch/fetchFactory.ts | Passes the access token into DPoP proof creation for ath binding. |
| packages/core/src/authenticatedFetch/dpopUtils.ts | Implements ath computation and adds an optional accessToken param to createDpopHeader. |
| packages/core/src/authenticatedFetch/dpopUtils.spec.ts | Adds tests ensuring ath is omitted when absent and correctly set when provided. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function accessTokenHash(accessToken: string): Promise<string> { | ||
| const digest = await crypto.subtle.digest( | ||
| "SHA-256", | ||
| new TextEncoder().encode(accessToken), | ||
| ); | ||
| return base64url.encode(new Uint8Array(digest)); | ||
| } |
There was a problem hiding this comment.
Good point on the encoding semantics. This is safe in practice because an OAuth 2.0 access token is ASCII-restricted: RFC 6750 §2.1 defines b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=", and JWT access tokens (RFC 9068) are dot-separated base64url — also ASCII. So TextEncoder (UTF-8) and ASCII produce identical bytes for any valid token, matching how jose/oauth4webapi compute the same hash internally. Happy to add an explicit ASCII assertion if you'd prefer the defensive contract. (A follow-up migrates this proof-creation path to panva's dpop package, which computes ath internally — removing this hand-rolled hash entirely.)
| async function accessTokenHash(accessToken: string): Promise<string> { | ||
| const digest = await crypto.subtle.digest( | ||
| "SHA-256", | ||
| new TextEncoder().encode(accessToken), | ||
| ); | ||
| return base64url.encode(new Uint8Array(digest)); | ||
| } |
There was a problem hiding this comment.
crypto.subtle doesn't add a new runtime requirement here: jose v6 — already this package's dependency — dropped Node ≤18 and runs on the WebCrypto global itself (crypto.subtle.importKey, etc.), and core's engines is Node 22+ (where globalThis.crypto is standard). So wherever jose works today, crypto.subtle is present; if it were absent, jose would already fail before reaching this code. I can add an explicit guard for a clearer error, but it wouldn't change the supported-runtime set. (The same follow-up dpop-package migration removes this crypto.subtle call from our code entirely.)
Problem
createDpopHeader(packages/core/src/authenticatedFetch/dpopUtils.ts) builds DPoP proofs with onlyhtu/htm/jti/iatand never emitsath— it still cites the pre-standarddraft-fett-oauth-dpop-04. RFC 9449 §7 requires that a DPoP proof accompanying an access token to a protected resource MUST carry anathclaim (§4.2:base64url(SHA-256(access_token))). Resource servers that enforce this reject ath-less proofs with 401, so apps built on@inrupt/solid-client-authn-browser(e.g. Penny, sleepy.bike) authenticate but then cannot read or write pod resources. (A known artefact of the pre-RFC DPoP era across the Solid ecosystem.) Fixes #3184.Change
createDpopHeadergains an optionalaccessToken; when present it addsath = base64url(SHA-256(token))to the proof payload. Omitting it preserves the previous payload, so the public signature stays backward-compatible — the token-endpoint call sites (oidc-browser) correctly continue to omitath.buildDpopFetchOptionspasses theauthTokenit already holds, so resource requests now carryath.jose'sbase64url+ the Web Cryptocrypto.subtleAPI thatjosev6 itself runs on; no new dependency and no platform-specific code path.Tests
athpresent and equal tobase64url(SHA-256(token))for a known token.accessTokenyields noath.Notes for maintainers
accessTokenoptional preserves the publiccreateDpopHeadersignature; if you'd prefer it required for resource requests, that's your call (possibly a major version).athdigest usescrypto.subtle— the same Web Crypto primitivejosev6 itself runs on, available in browsers and every Node versionjosev6 supports (the package engine is Node 22+). This matches howcorealready does crypto viajose, so no platform split is needed.🤖 Generated with Claude Code