Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/core/src/authenticatedFetch/dpopUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
28 changes: 26 additions & 2 deletions packages/core/src/authenticatedFetch/dpopUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(accessToken),
);
return base64url.encode(new Uint8Array(digest));
}
Comment on lines +51 to +57

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

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


/**
* 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<string> {
return new SignJWT({
htu: normalizeHTU(audience),
htm: method.toUpperCase(),
jti: v4(),
...(accessToken !== undefined
? { ath: await accessTokenHash(accessToken) }
: {}),
})
.setProtectedHeader({
alg: PREFERRED_SIGNING_ALG[0],
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/authenticatedFetch/fetchFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down