Skip to content

fix(core): include the RFC 9449 ath claim in DPoP proofs (closes #3184)#4292

Open
jeswr wants to merge 1 commit into
inrupt:mainfrom
jeswr:fix/dpop-ath-rfc9449
Open

fix(core): include the RFC 9449 ath claim in DPoP proofs (closes #3184)#4292
jeswr wants to merge 1 commit into
inrupt:mainfrom
jeswr:fix/dpop-ath-rfc9449

Conversation

@jeswr

@jeswr jeswr commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Problem

createDpopHeader (packages/core/src/authenticatedFetch/dpopUtils.ts) builds DPoP proofs with only htu/htm/jti/iat and never emits ath — it still cites 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 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

  • createDpopHeader gains an optional accessToken; when present it adds ath = 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 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; no new dependency and no platform-specific code path.

Tests

  • ath present and equal to base64url(SHA-256(token)) for a known token.
  • Backwards-compatible: omitting accessToken yields no ath.

Notes for maintainers

  • Keeping accessToken optional preserves the public createDpopHeader signature; if you'd prefer it required for resource requests, that's your call (possibly a major version).
  • The ath digest uses crypto.subtle — the same Web Crypto primitive jose v6 itself runs on, available in browsers and every Node version jose v6 supports (the package engine is Node 22+). This matches how core already does crypto via jose, so no platform split is needed.

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings June 8, 2026 21:11
@jeswr jeswr requested a review from a team as a code owner June 8, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to createDpopHeader and 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 ath claim.

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.

Comment on lines +51 to +57
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));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment on lines +51 to +57
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));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Required ath claim is missing from DPoP header

2 participants