Skip to content
Draft
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
711 changes: 711 additions & 0 deletions MIGRATION-oauth4webapi.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler {
code: url.searchParams.get("code") as string,
codeVerifier,
redirectUrl: storedRedirectIri,
// Phase 3 (auth-response validation placement): thread the full
// authorization-response URL and the expected `state` so `getTokens`
// validates `state`/`iss`/error via `oauth.validateAuthResponse` (the
// spec-correct check), rather than relying on a branded cast. `oauthState`
// is both the storage key for this session and the `state` the client
// originally sent, so it is the value oauth4webapi must match.
authResponseUrl: redirectUrl,
expectedState: oauthState,
},
isDpop,
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"build-docs-preview-site": "npm run build-api-docs; cd docs/api; make html"
},
"dependencies": {
"dpop": "^2",
"events": "^3.3.0",
"jose": "^6.2.3",
"uuid": "^14.0.0"
Expand Down
48 changes: 46 additions & 2 deletions 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 @@ -91,7 +91,51 @@ describe("createDpopHeader", () => {
);
expect(protectedHeader.alg).toBe("ES256");
expect(protectedHeader.typ).toBe("dpop+jwt");
expect(protectedHeader.jwk).toEqual((await mockKeyPair()).publicKey);
// The embedded JWK is now produced by the `dpop` package. It exports the
// canonical *public* members (`kty`, `crv`, `x`, `y` for EC) rather than the
// exact object inrupt previously passed through, so assert on the
// confirmation-relevant members rather than strict object equality.
const expectedJwk = (await mockKeyPair()).publicKey;
expect(protectedHeader.jwk).toMatchObject({
kty: expectedJwk.kty,
crv: expectedJwk.crv,
x: expectedJwk.x,
y: expectedJwk.y,
});
});

// POC: replaces the manual `ath` computation that PR #4292 added by hand.
// With the `dpop` package, `ath` is derived from the access token inside the
// library, so we just assert the spec-defined value is present and correct.
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);
});

it("omits the 'ath' claim when no access token is provided (back-compatible)", async () => {
const header = await createDpopHeader(
"https://some.resource",
"GET",
await mockKeyPair(),
);
const { payload } = await jwtVerify(header, (await mockJwk()).publicKey);
expect(payload.ath).toBeUndefined();
});
});

Expand Down
108 changes: 76 additions & 32 deletions packages/core/src/authenticatedFetch/dpopUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,57 +18,101 @@
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

// ---------------------------------------------------------------------------
// MIGRATION POC (proposal/migrate-to-oauth4webapi)
// ---------------------------------------------------------------------------
// This file is the *first slice* of the oauth4webapi + `dpop` migration
// described in `MIGRATION-oauth4webapi.md` at the repo root.
//
// Before: `createDpopHeader` hand-rolled a DPoP proof JWT with jose's `SignJWT`,
// and (after PR #4292) hand-computed the RFC 9449 §4.2 `ath` claim with
// `crypto.subtle.digest`.
//
// After: the proof is produced by panva's `dpop` package (`DPoP.generateProof`,
// same author as `jose`). Passing the access token as the final argument makes
// the `ath` claim automatic and spec-correct, so this POC *subsumes PR #4292*
// (the manual `ath` fix) by getting `ath` for free from the library. The `jti`,
// `htu` normalisation, `iat`, and the `jwk`/`typ: dpop+jwt` protected header are
// all handled inside `dpop` too — so the hand-rolled body below shrinks to a
// thin adapter.
//
// The only impedance mismatch: inrupt's public `KeyPair` type stores
// `publicKey` as a `JWK` (it is serialised into IndexedDB / session storage),
// whereas `DPoP.generateProof` expects a `CryptoKeyPair` whose `publicKey` is a
// `CryptoKey`. We bridge that here by importing the JWK back to a `CryptoKey`
// with jose's `importJWK`. The *full* migration (Phase 2+) keeps the DPoP key as
// an `oauth.generateKeyPair("ES256", { extractable: false })` `CryptoKeyPair`
// end-to-end and threads a single `oauth.DPoP()` handle through the token grants,
// eliminating this JWK round-trip entirely (see the doc).
// ---------------------------------------------------------------------------

import type { JWK, CryptoKey } from "jose";
import { SignJWT, generateKeyPair, exportJWK } from "jose";
import { v4 } from "uuid";
import { generateKeyPair, exportJWK, importJWK } from "jose";
// panva/dpop v2 — one-shot DPoP proof creation (RFC 9449), incl. `ath` + nonce.
import * as DPoP from "dpop";
import { PREFERRED_SIGNING_ALG } from "../constant";

/**
* Normalizes a URL in order to generate the DPoP token based on a consistent scheme.
*
* @param audience The URL to normalize.
* @returns The normalized URL as a string.
* @hidden
*/
function normalizeHTU(audience: string): string {
const audienceUrl = new URL(audience);
return new URL(audienceUrl.pathname, audienceUrl.origin).toString();
}

export type KeyPair = {
privateKey: CryptoKey;
publicKey: JWK;
};

/**
* Creates a DPoP header according to https://tools.ietf.org/html/draft-fett-oauth-dpop-04,
* based on the target URL and method, using the provided key.
* Creates a DPoP proof according to {@link https://www.rfc-editor.org/rfc/rfc9449 RFC 9449},
* bound to the target URL and method, signed with the provided key — delegating
* to panva's `dpop` package rather than hand-assembling the JWT.
*
* @param audience Target URL.
* @param method HTTP method allowed.
* @param dpopKey Key used to sign the token.
* @returns A JWT that can be used as a DPoP Authorization header.
* @param audience Target URL (becomes the normalised `htu` claim).
* @param method HTTP method (becomes the `htm` claim).
* @param dpopKey Key used to sign the proof.
* @param accessToken When provided, the proof is bound to this access token via
* the RFC 9449 `ath` claim (§4.2/§7). `dpop` computes
* `base64url(SHA-256(ASCII(token)))` internally — no manual hashing needed. This
* is REQUIRED whenever the proof accompanies an access token to a protected
* resource, or a conforming resource server returns 401. (Replaces the manual
* `ath` computation added in PR #4292.)
* @param nonce Optional DPoP-Nonce echoed back from a `DPoP-Nonce` HTTP header /
* `use_dpop_nonce` error. The full migration wires this through automatically via
* the `oauth.DPoP()` handle; exposed here so callers can forward a server nonce.
* @returns A compact JWT usable as the value of the `DPoP` header.
*/
export async function createDpopHeader(
audience: string,
method: string,
dpopKey: KeyPair,
accessToken?: string,
nonce?: string,
): Promise<string> {
return new SignJWT({
htu: normalizeHTU(audience),
htm: method.toUpperCase(),
jti: v4(),
})
.setProtectedHeader({
alg: PREFERRED_SIGNING_ALG[0],
jwk: dpopKey.publicKey,
typ: "dpop+jwt",
})
.setIssuedAt()
.sign(dpopKey.privateKey, {});
// `dpop` needs a CryptoKeyPair; inrupt's KeyPair carries the public half as a
// JWK, so re-import it. `importJWK` returns `CryptoKey | Uint8Array`; for the
// asymmetric algs we use (ES256/RS256) it is always a `CryptoKey`.
const publicKey = (await importJWK(
dpopKey.publicKey,
dpopKey.publicKey.alg ?? PREFERRED_SIGNING_ALG[0],
)) as CryptoKey;

// `DPoP.generateProof(keypair, htu, htm, nonce, accessToken)`:
// - normalises `htu` (strips query/fragment/userinfo) like the old normalizeHTU,
// - sets `htm`, a random `jti`, `iat`, and the `typ: "dpop+jwt"` + embedded
// `jwk` protected header,
// - derives the `ath` claim from `accessToken` when present.
return DPoP.generateProof(
{ privateKey: dpopKey.privateKey, publicKey },
audience,
method.toUpperCase(),
nonce,
accessToken,
);
}

export async function generateDpopKeyPair(): Promise<KeyPair> {
// Unchanged shape (extractable so the public half can be serialised to a JWK
// and persisted, and the refresh flow can re-import the SAME bound key).
// TODO(migration): persist a non-extractable `oauth.generateKeyPair("ES256")`
// CryptoKeyPair directly (IndexedDB stores CryptoKeys), dropping the JWK
// round-trip. This is a storage-format change and is out of scope here — see
// MIGRATION-oauth4webapi.md. Consistent with the same note in both
// `oauthAdapter.ts` seams.
const { privateKey, publicKey } = await generateKeyPair(
PREFERRED_SIGNING_ALG[0],
{ extractable: true },
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/authenticatedFetch/fetchFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,19 @@ async function buildDpopFetchOptions(
const headers = new Headers(defaultOptions?.headers);
// Any pre-existing Authorization header should be overriden.
headers.set("Authorization", `DPoP ${authToken}`);
// POC (oauth4webapi migration): pass the access token so the proof carries the
// RFC 9449 `ath` claim (§7) — required when a DPoP proof accompanies an access
// token to a protected resource, or a conforming RS rejects it with 401. With
// the `dpop` package this is a single extra argument; `ath` is computed inside
// the library. This is the behavioural fix that PR #4292 added by hand.
headers.set(
"DPoP",
await createDpopHeader(targetUrl, defaultOptions?.method ?? "get", dpopKey),
await createDpopHeader(
targetUrl,
defaultOptions?.method ?? "get",
dpopKey,
authToken,
),
);
return {
...defaultOptions,
Expand Down
2 changes: 1 addition & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"dependencies": {
"@inrupt/solid-client-authn-core": "^5.0.0",
"jose": "^6.2.3",
"openid-client": "^5.7.1",
"oauth4webapi": "^3.0.0",
"uuid": "^14.0.0"
},
"publishConfig": {
Expand Down
Loading