@dwk/dpop 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 David W. Keith
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # `@dwk/dpop`
2
+
3
+ > DPoP (RFC 9449) proof verification. Cross-standard reusable.
4
+
5
+ Part of the [`@dwk` IndieWeb + Solid cohort](../../README.md). See the
6
+ [package specification](../../spec/packages/dpop.md) for the full requirements.
7
+
8
+ This package is **cross-standard reusable**: it takes plain-data inputs only,
9
+ has no Workers-runtime dependency (only Web Crypto), and unit-tests in isolation
10
+ (Node, no `workerd`). It is **protocol-agnostic** — it knows nothing about
11
+ IndieAuth or Solid. The caller supplies the request facts and any access-token
12
+ binding it expects, and owns replay detection via the returned `jti`.
13
+
14
+ ## API
15
+
16
+ ```ts
17
+ import { verifyDpopProof } from "@dwk/dpop";
18
+
19
+ const result = await verifyDpopProof({
20
+ proof: request.headers.get("DPoP")!, // the DPoP proof JWT
21
+ htm: request.method, // HTTP method, e.g. "POST"
22
+ htu: "https://pod.example/resource", // request URI (query/fragment ignored)
23
+ // now, // epoch seconds; defaults to Date.now()
24
+ // maxAgeSeconds, // iat clock-skew window; defaults to 300
25
+ });
26
+
27
+ if (!result.valid) {
28
+ // result.reason is a stable code, e.g. "htu_mismatch", "signature_invalid"
29
+ return new Response("invalid DPoP proof", { status: 401 });
30
+ }
31
+
32
+ // Enforce your own replay policy with the verified jti.
33
+ if (await seenBefore(result.jti)) return new Response("DPoP replay", { status: 401 });
34
+ ```
35
+
36
+ ### Resource Server: token binding
37
+
38
+ When a request carries a DPoP-bound access token, pass the token and the token's
39
+ `cnf.jkt` to verify the binding:
40
+
41
+ ```ts
42
+ const result = await verifyDpopProof({
43
+ proof,
44
+ htm,
45
+ htu,
46
+ accessToken, // proof MUST carry ath = base64url(SHA-256(accessToken))
47
+ expectedJkt, // proof key thumbprint MUST equal this
48
+ });
49
+ ```
50
+
51
+ A Resource Server enforcing `ath` MUST pass `expectedJkt` too: `ath` only proves
52
+ the proof was made for this token, while `cnf.jkt` proves the proof key is the
53
+ one the token was issued to. Supplying `accessToken` without `expectedJkt` is
54
+ rejected with `jkt_required` rather than validating an unbound proof
55
+ (RFC 9449 §7.1).
56
+
57
+ ## What is verified
58
+
59
+ - **Header** — `typ` is exactly `dpop+jwt`; no `crit` parameter is present
60
+ (RFC 7515 §4.1.11); `alg` is an asymmetric algorithm from the allow-list
61
+ (`ES256`, `ES384`, `RS256`, `PS256` — never `none` or HMAC); `jwk` is present,
62
+ carries no private key material, has an EC `crv` matching the `alg`, and (for
63
+ RSA) a modulus of at least 2048 bits.
64
+ - **Signature** — over `header.payload` using the embedded `jwk`.
65
+ - **Claims** — `htm` matches the request method (case-insensitive); `htu`
66
+ matches the request URI after normalization (scheme/host lowercased, default
67
+ port and any query/fragment removed); `iat` is within the clock-skew window;
68
+ `jti` is a present, non-empty string.
69
+ - **Bindings** (optional) — `ath` matches `base64url(SHA-256(accessToken))`; the
70
+ computed `jkt` (RFC 7638 thumbprint) equals `expectedJkt`.
71
+ - **Server nonce** (optional, RFC 9449 §8/§9) — when `expectedNonce` is supplied,
72
+ the proof's `nonce` claim must equal it (else `nonce_mismatch`). The proof's
73
+ `nonce` is surfaced on the result either way so a caller can answer a mismatch
74
+ with a `use_dpop_nonce` error and a fresh `DPoP-Nonce`.
75
+
76
+ `verifyDpopProof` never throws — failures return `{ valid: false, reason }` with
77
+ a stable `DpopFailureReason` code (the proof's `nonce` is also surfaced on a
78
+ `nonce_mismatch`). On success it returns `{ valid: true, jti, jkt, nonce? }`.
79
+
80
+ ## Out of scope
81
+
82
+ - Replay detection storage (the caller owns the `jti` cache).
83
+ - `DPoP-Nonce` issuance and rotation, and emitting the `use_dpop_nonce` error
84
+ (the caller owns the nonce lifecycle; this lib only checks the proof's `nonce`
85
+ against the `expectedNonce` it is given).
86
+ - Access-token validation beyond the DPoP binding checks.
87
+
88
+ ## License
89
+
90
+ [ISC](../../LICENSE)
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `@dwk/dpop` — DPoP (RFC 9449) proof verification.
3
+ *
4
+ * A pure, runtime-agnostic library: HTTP request facts and token claims go in,
5
+ * a verification result comes out. It performs no I/O beyond Web Crypto, holds
6
+ * no state, and needs no Cloudflare bindings, so it unit-tests without a
7
+ * Workers runtime.
8
+ *
9
+ * The library stays protocol-agnostic — it knows nothing about IndieAuth or
10
+ * Solid. The caller supplies the request facts and any access-token binding it
11
+ * expects, and owns replay detection via the returned `jti`.
12
+ *
13
+ * @see https://datatracker.ietf.org/doc/html/rfc9449
14
+ * @see spec/packages/dpop.md
15
+ * @packageDocumentation
16
+ */
17
+ /** Default clock-skew window (seconds) applied to `iat` when none is given. */
18
+ export declare const DEFAULT_MAX_AGE_SECONDS = 300;
19
+ /**
20
+ * Asymmetric signature algorithms accepted in the DPoP proof JOSE header.
21
+ *
22
+ * Symmetric (`HS*`) and `none` are deliberately excluded: a DPoP proof must be
23
+ * signed by the client-held private key whose public half is embedded as `jwk`.
24
+ */
25
+ export type DpopAlgorithm = "ES256" | "ES384" | "RS256" | "PS256";
26
+ /** Stable, locale-independent failure codes returned in {@link DpopVerifyResult.reason}. */
27
+ export type DpopFailureReason = "proof_malformed" | "header_invalid" | "payload_invalid" | "typ_invalid" | "crit_unsupported" | "alg_unsupported" | "jwk_missing" | "jwk_private" | "jwk_invalid" | "crv_mismatch" | "rsa_key_too_small" | "signature_invalid" | "htm_mismatch" | "htu_invalid" | "htu_mismatch" | "iat_invalid" | "proof_expired" | "proof_future" | "jti_missing" | "nonce_mismatch" | "ath_mismatch" | "jkt_required" | "jkt_mismatch";
28
+ /** Plain-data inputs required to verify a DPoP proof. */
29
+ export interface DpopVerifyInput {
30
+ /** The DPoP proof JWT (compact JWS) from the `DPoP` header. */
31
+ proof: string;
32
+ /** HTTP method of the request the proof is bound to, e.g. `"POST"`. */
33
+ htm: string;
34
+ /** HTTP target URI of the request (query/fragment are ignored per §4.3). */
35
+ htu: string;
36
+ /**
37
+ * Expected access-token hash binding (Resource Server case). When provided,
38
+ * the proof MUST carry an `ath` claim equal to `base64url(SHA-256(accessToken))`.
39
+ *
40
+ * A Resource Server enforcing `ath` MUST also supply {@link expectedJkt}: the
41
+ * `ath` proves the proof was made for this token, but only the `cnf.jkt`
42
+ * binding proves the proof key is the one the token was issued to. Passing
43
+ * `accessToken` without `expectedJkt` defeats proof-of-possession
44
+ * (RFC 9449 §7.1) and is rejected with `jkt_required` rather than validating.
45
+ */
46
+ accessToken?: string;
47
+ /**
48
+ * Token confirmation thumbprint (`cnf.jkt`) to match against the proof key.
49
+ * Required whenever {@link accessToken} is supplied (see its note).
50
+ */
51
+ expectedJkt?: string;
52
+ /**
53
+ * Server-provided DPoP nonce the proof must carry (RFC 9449 §8/§9). When set,
54
+ * the proof MUST carry a `nonce` claim equal to this value, else it is
55
+ * rejected with `nonce_mismatch`. An AS/RS uses this to bound proof lifetime
56
+ * and force fresh proofs as a replay defense: on a mismatch (or when no nonce
57
+ * was sent yet) the caller answers with a `use_dpop_nonce` error carrying a
58
+ * fresh `DPoP-Nonce`. Issuing and rotating the nonce is the caller's job; this
59
+ * library only checks equality and surfaces the proof's {@link DpopVerifyResult.nonce}.
60
+ */
61
+ expectedNonce?: string;
62
+ /** Current time in seconds since the epoch. Defaults to `Date.now()`. */
63
+ now?: number;
64
+ /** Allowed clock skew in seconds for the `iat` window. Defaults to {@link DEFAULT_MAX_AGE_SECONDS}. */
65
+ maxAgeSeconds?: number;
66
+ }
67
+ /** Result of verifying a DPoP proof. */
68
+ export interface DpopVerifyResult {
69
+ /** Whether the proof is valid. */
70
+ valid: boolean;
71
+ /** The verified JWT ID (`jti`) for replay detection by the caller. */
72
+ jti?: string;
73
+ /** The RFC 7638 thumbprint of the proof key (`jkt`). */
74
+ jkt?: string;
75
+ /**
76
+ * The proof's `nonce` claim, when it carried one (string only). Surfaced on
77
+ * both success and a `nonce_mismatch` so a caller enforcing the
78
+ * `DPoP-Nonce` mechanism (RFC 9449 §8/§9) can decide whether to answer with a
79
+ * `use_dpop_nonce` error and a fresh nonce.
80
+ */
81
+ nonce?: string;
82
+ /** Stable failure code (see {@link DpopFailureReason}) when `valid` is false. */
83
+ reason?: DpopFailureReason;
84
+ }
85
+ /**
86
+ * Verify a DPoP proof JWT and, optionally, its binding to an access token.
87
+ *
88
+ * Pure async function; performs no I/O beyond Web Crypto. Never throws — every
89
+ * failure is returned as `{ valid: false, reason }` with a stable
90
+ * {@link DpopFailureReason} code.
91
+ *
92
+ * @param input - Request facts, the proof, and any expected token binding.
93
+ * @returns On success `{ valid: true, jti, jkt }`; otherwise `{ valid: false, reason }`.
94
+ */
95
+ export declare function verifyDpopProof(input: DpopVerifyInput): Promise<DpopVerifyResult>;
96
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;AAElE,4FAA4F;AAC5F,MAAM,MAAM,iBAAiB,GACzB,iBAAiB,GACjB,gBAAgB,GAChB,iBAAiB,GACjB,aAAa,GACb,kBAAkB,GAClB,iBAAiB,GACjB,aAAa,GACb,aAAa,GACb,aAAa,GACb,cAAc,GACd,mBAAmB,GACnB,mBAAmB,GACnB,cAAc,GACd,aAAa,GACb,cAAc,GACd,aAAa,GACb,eAAe,GACf,cAAc,GACd,aAAa,GACb,gBAAgB,GAChB,cAAc,GACd,cAAc,GACd,cAAc,CAAC;AAEnB,yDAAyD;AACzD,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uGAAuG;IACvG,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wCAAwC;AACxC,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAkMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA6L3B"}
package/dist/index.js ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `@dwk/dpop` — DPoP (RFC 9449) proof verification.
3
+ *
4
+ * A pure, runtime-agnostic library: HTTP request facts and token claims go in,
5
+ * a verification result comes out. It performs no I/O beyond Web Crypto, holds
6
+ * no state, and needs no Cloudflare bindings, so it unit-tests without a
7
+ * Workers runtime.
8
+ *
9
+ * The library stays protocol-agnostic — it knows nothing about IndieAuth or
10
+ * Solid. The caller supplies the request facts and any access-token binding it
11
+ * expects, and owns replay detection via the returned `jti`.
12
+ *
13
+ * @see https://datatracker.ietf.org/doc/html/rfc9449
14
+ * @see spec/packages/dpop.md
15
+ * @packageDocumentation
16
+ */
17
+ /** Default clock-skew window (seconds) applied to `iat` when none is given. */
18
+ export const DEFAULT_MAX_AGE_SECONDS = 300;
19
+ const ALGS = {
20
+ ES256: {
21
+ kty: "EC",
22
+ expectedCrv: "P-256",
23
+ importParams: { name: "ECDSA", namedCurve: "P-256" },
24
+ verifyParams: { name: "ECDSA", hash: "SHA-256" },
25
+ },
26
+ ES384: {
27
+ kty: "EC",
28
+ expectedCrv: "P-384",
29
+ importParams: { name: "ECDSA", namedCurve: "P-384" },
30
+ verifyParams: { name: "ECDSA", hash: "SHA-384" },
31
+ },
32
+ RS256: {
33
+ kty: "RSA",
34
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
35
+ verifyParams: { name: "RSASSA-PKCS1-v1_5" },
36
+ },
37
+ PS256: {
38
+ kty: "RSA",
39
+ importParams: { name: "RSA-PSS", hash: "SHA-256" },
40
+ verifyParams: { name: "RSA-PSS", saltLength: 32 },
41
+ },
42
+ };
43
+ /**
44
+ * Minimum accepted RSA modulus size in bits. Keys below this are rejected
45
+ * regardless of a valid signature: an attacker who controls an undersized
46
+ * key's private half could otherwise mint accepted proofs.
47
+ */
48
+ const MIN_RSA_KEY_BITS = 2048;
49
+ /** RSA/EC private-key JWK members; their presence means a private key was sent. */
50
+ const PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi"];
51
+ const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
52
+ function fail(reason) {
53
+ return { valid: false, reason };
54
+ }
55
+ function base64urlToBytes(segment) {
56
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
57
+ const padded = b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
58
+ const binary = atob(padded);
59
+ const bytes = new Uint8Array(binary.length);
60
+ for (let i = 0; i < binary.length; i++) {
61
+ bytes[i] = binary.charCodeAt(i);
62
+ }
63
+ return bytes;
64
+ }
65
+ function bytesToBase64url(bytes) {
66
+ let binary = "";
67
+ for (const byte of bytes) {
68
+ binary += String.fromCharCode(byte);
69
+ }
70
+ return btoa(binary)
71
+ .replace(/\+/g, "-")
72
+ .replace(/\//g, "_")
73
+ .replace(/=+$/, "");
74
+ }
75
+ function decodeJsonSegment(segment) {
76
+ const text = new TextDecoder().decode(base64urlToBytes(segment));
77
+ return JSON.parse(text);
78
+ }
79
+ function isObject(value) {
80
+ return typeof value === "object" && value !== null && !Array.isArray(value);
81
+ }
82
+ /**
83
+ * Bit length of an RSA modulus encoded as a base64url big-endian integer
84
+ * (the JWK `n` member). Leading zero bytes are ignored. Returns 0 if `n`
85
+ * cannot be decoded or is empty.
86
+ */
87
+ function rsaModulusBits(n) {
88
+ let bytes;
89
+ try {
90
+ bytes = base64urlToBytes(n);
91
+ }
92
+ catch {
93
+ return 0;
94
+ }
95
+ let i = 0;
96
+ while (i < bytes.length && bytes[i] === 0)
97
+ i++;
98
+ if (i >= bytes.length)
99
+ return 0;
100
+ let bits = (bytes.length - i - 1) * 8;
101
+ for (let v = bytes[i]; v > 0; v >>= 1)
102
+ bits++;
103
+ return bits;
104
+ }
105
+ /**
106
+ * Normalize an HTTP URI for `htu` comparison: lowercase the scheme and host
107
+ * (the URL parser does this), drop default ports, and strip query and fragment.
108
+ * Returns `null` when the URI cannot be parsed.
109
+ */
110
+ function normalizeHtu(uri) {
111
+ try {
112
+ const url = new URL(uri);
113
+ return `${url.protocol}//${url.host}${url.pathname}`;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ async function sha256Base64url(input) {
120
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
121
+ return bytesToBase64url(new Uint8Array(digest));
122
+ }
123
+ /**
124
+ * Compute the RFC 7638 JWK thumbprint (base64url SHA-256 over the canonical,
125
+ * lexicographically-ordered required members). Returns `null` if the required
126
+ * members for the key type are missing.
127
+ */
128
+ async function jwkThumbprint(jwk) {
129
+ let canonical;
130
+ if (jwk.kty === "EC") {
131
+ const { crv, x, y } = jwk;
132
+ if (typeof crv !== "string" ||
133
+ typeof x !== "string" ||
134
+ typeof y !== "string") {
135
+ return null;
136
+ }
137
+ canonical = JSON.stringify({ crv, kty: "EC", x, y });
138
+ }
139
+ else if (jwk.kty === "RSA") {
140
+ const { e, n } = jwk;
141
+ if (typeof e !== "string" || typeof n !== "string") {
142
+ return null;
143
+ }
144
+ canonical = JSON.stringify({ e, kty: "RSA", n });
145
+ }
146
+ else {
147
+ return null;
148
+ }
149
+ return sha256Base64url(canonical);
150
+ }
151
+ /** Build a clean public-only JWK for `importKey`, dropping `alg`/`use`/`key_ops`. */
152
+ function publicJwk(jwk, kty) {
153
+ if (kty === "EC") {
154
+ const { crv, x, y } = jwk;
155
+ if (typeof crv !== "string" ||
156
+ typeof x !== "string" ||
157
+ typeof y !== "string") {
158
+ return null;
159
+ }
160
+ return { kty: "EC", crv, x, y };
161
+ }
162
+ const { n, e } = jwk;
163
+ if (typeof n !== "string" || typeof e !== "string") {
164
+ return null;
165
+ }
166
+ return { kty: "RSA", n, e };
167
+ }
168
+ /**
169
+ * Verify a DPoP proof JWT and, optionally, its binding to an access token.
170
+ *
171
+ * Pure async function; performs no I/O beyond Web Crypto. Never throws — every
172
+ * failure is returned as `{ valid: false, reason }` with a stable
173
+ * {@link DpopFailureReason} code.
174
+ *
175
+ * @param input - Request facts, the proof, and any expected token binding.
176
+ * @returns On success `{ valid: true, jti, jkt }`; otherwise `{ valid: false, reason }`.
177
+ */
178
+ export async function verifyDpopProof(input) {
179
+ const { proof, htm, htu } = input;
180
+ const now = input.now ?? Math.floor(Date.now() / 1000);
181
+ const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
182
+ // 1. Parse the compact JWS: exactly three non-empty base64url segments.
183
+ if (typeof proof !== "string") {
184
+ return fail("proof_malformed");
185
+ }
186
+ const segments = proof.split(".");
187
+ if (segments.length !== 3) {
188
+ return fail("proof_malformed");
189
+ }
190
+ const [headerSeg, payloadSeg, signatureSeg] = segments;
191
+ if (!BASE64URL_SEGMENT.test(headerSeg) ||
192
+ !BASE64URL_SEGMENT.test(payloadSeg) ||
193
+ !BASE64URL_SEGMENT.test(signatureSeg)) {
194
+ return fail("proof_malformed");
195
+ }
196
+ let header;
197
+ try {
198
+ header = decodeJsonSegment(headerSeg);
199
+ }
200
+ catch {
201
+ return fail("header_invalid");
202
+ }
203
+ if (!isObject(header)) {
204
+ return fail("header_invalid");
205
+ }
206
+ // 2. Header checks: typ, crit, alg allow-list, public-only jwk.
207
+ if (header.typ !== "dpop+jwt") {
208
+ return fail("typ_invalid");
209
+ }
210
+ // RFC 7515 §4.1.11: reject any JWS carrying critical header parameters this
211
+ // library does not understand. It understands no extensions, so any `crit`.
212
+ if ("crit" in header) {
213
+ return fail("crit_unsupported");
214
+ }
215
+ const alg = header.alg;
216
+ const algSpec = typeof alg === "string" ? ALGS[alg] : undefined;
217
+ if (!algSpec) {
218
+ return fail("alg_unsupported");
219
+ }
220
+ const jwk = header.jwk;
221
+ if (!isObject(jwk)) {
222
+ return fail("jwk_missing");
223
+ }
224
+ if (PRIVATE_JWK_MEMBERS.some((member) => member in jwk)) {
225
+ return fail("jwk_private");
226
+ }
227
+ if (jwk.kty !== algSpec.kty) {
228
+ return fail("jwk_invalid");
229
+ }
230
+ // EC: the curve must be the one the alg implies (ES256⇒P-256, …). WebCrypto
231
+ // would also reject a mismatch on import, but check it explicitly up front.
232
+ // A missing/non-string `crv` is malformed, not a mismatch — let it fall
233
+ // through to `publicJwk` below, which rejects it as `jwk_invalid`.
234
+ if (algSpec.kty === "EC" &&
235
+ typeof jwk.crv === "string" &&
236
+ jwk.crv !== algSpec.expectedCrv) {
237
+ return fail("crv_mismatch");
238
+ }
239
+ // RSA: reject undersized moduli whose private half an attacker could control.
240
+ // A missing/non-string `n` is malformed, not undersized — let it fall through
241
+ // to `publicJwk` below, which rejects it as `jwk_invalid`.
242
+ if (algSpec.kty === "RSA") {
243
+ const n = jwk.n;
244
+ if (typeof n === "string" && rsaModulusBits(n) < MIN_RSA_KEY_BITS) {
245
+ return fail("rsa_key_too_small");
246
+ }
247
+ }
248
+ const importable = publicJwk(jwk, algSpec.kty);
249
+ if (importable === null) {
250
+ return fail("jwk_invalid");
251
+ }
252
+ // 3. Verify the signature over `header.payload` using the embedded jwk.
253
+ let signatureValid;
254
+ try {
255
+ const key = await crypto.subtle.importKey("jwk", importable, algSpec.importParams, false, ["verify"]);
256
+ signatureValid = await crypto.subtle.verify(algSpec.verifyParams, key, base64urlToBytes(signatureSeg), new TextEncoder().encode(`${headerSeg}.${payloadSeg}`));
257
+ }
258
+ catch {
259
+ return fail("jwk_invalid");
260
+ }
261
+ if (!signatureValid) {
262
+ return fail("signature_invalid");
263
+ }
264
+ let payload;
265
+ try {
266
+ payload = decodeJsonSegment(payloadSeg);
267
+ }
268
+ catch {
269
+ return fail("payload_invalid");
270
+ }
271
+ if (!isObject(payload)) {
272
+ return fail("payload_invalid");
273
+ }
274
+ // 4. Claim checks: htm, htu, iat window, jti.
275
+ if (typeof payload.htm !== "string" ||
276
+ payload.htm.toUpperCase() !== htm.toUpperCase()) {
277
+ return fail("htm_mismatch");
278
+ }
279
+ const expectedHtu = normalizeHtu(htu);
280
+ if (expectedHtu === null) {
281
+ return fail("htu_invalid");
282
+ }
283
+ const proofHtu = typeof payload.htu === "string" ? normalizeHtu(payload.htu) : null;
284
+ if (proofHtu === null || proofHtu !== expectedHtu) {
285
+ return fail("htu_mismatch");
286
+ }
287
+ const iat = payload.iat;
288
+ if (typeof iat !== "number" || !Number.isFinite(iat)) {
289
+ return fail("iat_invalid");
290
+ }
291
+ if (iat < now - maxAgeSeconds) {
292
+ return fail("proof_expired");
293
+ }
294
+ if (iat > now + maxAgeSeconds) {
295
+ return fail("proof_future");
296
+ }
297
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) {
298
+ return fail("jti_missing");
299
+ }
300
+ const jti = payload.jti;
301
+ // Server-provided nonce (RFC 9449 §4.3 step 10): when the caller issued a
302
+ // nonce, the proof's `nonce` claim MUST equal it. Surface the proof's nonce
303
+ // either way so the caller can answer a mismatch with `use_dpop_nonce`.
304
+ const nonce = typeof payload.nonce === "string" ? payload.nonce : undefined;
305
+ if (input.expectedNonce !== undefined && nonce !== input.expectedNonce) {
306
+ return { valid: false, reason: "nonce_mismatch", nonce };
307
+ }
308
+ // Compute the thumbprint once, for both the cnf.jkt check and the result.
309
+ const jkt = await jwkThumbprint(jwk);
310
+ if (jkt === null) {
311
+ return fail("jwk_invalid");
312
+ }
313
+ // 5. Resource Server: access-token hash binding. Enforcing `ath` without the
314
+ // `cnf.jkt` binding would let a proof made for the token but signed by any
315
+ // key validate, defeating proof-of-possession (RFC 9449 §7.1), so an
316
+ // access token requires the expected thumbprint too.
317
+ if (input.accessToken !== undefined) {
318
+ if (input.expectedJkt === undefined) {
319
+ return fail("jkt_required");
320
+ }
321
+ const expectedAth = await sha256Base64url(input.accessToken);
322
+ if (typeof payload.ath !== "string" || payload.ath !== expectedAth) {
323
+ return fail("ath_mismatch");
324
+ }
325
+ }
326
+ // 6. Resource Server: token confirmation thumbprint binding.
327
+ if (input.expectedJkt !== undefined && input.expectedJkt !== jkt) {
328
+ return fail("jkt_mismatch");
329
+ }
330
+ // 7. Success.
331
+ return { valid: true, jti, jkt, nonce };
332
+ }
333
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@dwk/dpop",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "DPoP (RFC 9449) proof verification. Cross-standard reusable; no Workers runtime dependency.",
5
+ "keywords": [
6
+ "dpop",
7
+ "rfc9449",
8
+ "oauth2",
9
+ "proof-of-possession",
10
+ "access-token",
11
+ "web-crypto"
12
+ ],
13
+ "type": "module",
14
+ "license": "ISC",
15
+ "author": "David W. Keith <me@dwk.io>",
16
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/dpop#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/davidwkeith/workers.git",
20
+ "directory": "packages/dpop"
21
+ },
22
+ "sideEffects": false,
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "!src/**/*.test.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "typecheck": "tsc -p tsconfig.json",
42
+ "clean": "rm -rf dist"
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,507 @@
1
+ /**
2
+ * `@dwk/dpop` — DPoP (RFC 9449) proof verification.
3
+ *
4
+ * A pure, runtime-agnostic library: HTTP request facts and token claims go in,
5
+ * a verification result comes out. It performs no I/O beyond Web Crypto, holds
6
+ * no state, and needs no Cloudflare bindings, so it unit-tests without a
7
+ * Workers runtime.
8
+ *
9
+ * The library stays protocol-agnostic — it knows nothing about IndieAuth or
10
+ * Solid. The caller supplies the request facts and any access-token binding it
11
+ * expects, and owns replay detection via the returned `jti`.
12
+ *
13
+ * @see https://datatracker.ietf.org/doc/html/rfc9449
14
+ * @see spec/packages/dpop.md
15
+ * @packageDocumentation
16
+ */
17
+
18
+ /** Default clock-skew window (seconds) applied to `iat` when none is given. */
19
+ export const DEFAULT_MAX_AGE_SECONDS = 300;
20
+
21
+ /**
22
+ * Asymmetric signature algorithms accepted in the DPoP proof JOSE header.
23
+ *
24
+ * Symmetric (`HS*`) and `none` are deliberately excluded: a DPoP proof must be
25
+ * signed by the client-held private key whose public half is embedded as `jwk`.
26
+ */
27
+ export type DpopAlgorithm = "ES256" | "ES384" | "RS256" | "PS256";
28
+
29
+ /** Stable, locale-independent failure codes returned in {@link DpopVerifyResult.reason}. */
30
+ export type DpopFailureReason =
31
+ | "proof_malformed"
32
+ | "header_invalid"
33
+ | "payload_invalid"
34
+ | "typ_invalid"
35
+ | "crit_unsupported"
36
+ | "alg_unsupported"
37
+ | "jwk_missing"
38
+ | "jwk_private"
39
+ | "jwk_invalid"
40
+ | "crv_mismatch"
41
+ | "rsa_key_too_small"
42
+ | "signature_invalid"
43
+ | "htm_mismatch"
44
+ | "htu_invalid"
45
+ | "htu_mismatch"
46
+ | "iat_invalid"
47
+ | "proof_expired"
48
+ | "proof_future"
49
+ | "jti_missing"
50
+ | "nonce_mismatch"
51
+ | "ath_mismatch"
52
+ | "jkt_required"
53
+ | "jkt_mismatch";
54
+
55
+ /** Plain-data inputs required to verify a DPoP proof. */
56
+ export interface DpopVerifyInput {
57
+ /** The DPoP proof JWT (compact JWS) from the `DPoP` header. */
58
+ proof: string;
59
+ /** HTTP method of the request the proof is bound to, e.g. `"POST"`. */
60
+ htm: string;
61
+ /** HTTP target URI of the request (query/fragment are ignored per §4.3). */
62
+ htu: string;
63
+ /**
64
+ * Expected access-token hash binding (Resource Server case). When provided,
65
+ * the proof MUST carry an `ath` claim equal to `base64url(SHA-256(accessToken))`.
66
+ *
67
+ * A Resource Server enforcing `ath` MUST also supply {@link expectedJkt}: the
68
+ * `ath` proves the proof was made for this token, but only the `cnf.jkt`
69
+ * binding proves the proof key is the one the token was issued to. Passing
70
+ * `accessToken` without `expectedJkt` defeats proof-of-possession
71
+ * (RFC 9449 §7.1) and is rejected with `jkt_required` rather than validating.
72
+ */
73
+ accessToken?: string;
74
+ /**
75
+ * Token confirmation thumbprint (`cnf.jkt`) to match against the proof key.
76
+ * Required whenever {@link accessToken} is supplied (see its note).
77
+ */
78
+ expectedJkt?: string;
79
+ /**
80
+ * Server-provided DPoP nonce the proof must carry (RFC 9449 §8/§9). When set,
81
+ * the proof MUST carry a `nonce` claim equal to this value, else it is
82
+ * rejected with `nonce_mismatch`. An AS/RS uses this to bound proof lifetime
83
+ * and force fresh proofs as a replay defense: on a mismatch (or when no nonce
84
+ * was sent yet) the caller answers with a `use_dpop_nonce` error carrying a
85
+ * fresh `DPoP-Nonce`. Issuing and rotating the nonce is the caller's job; this
86
+ * library only checks equality and surfaces the proof's {@link DpopVerifyResult.nonce}.
87
+ */
88
+ expectedNonce?: string;
89
+ /** Current time in seconds since the epoch. Defaults to `Date.now()`. */
90
+ now?: number;
91
+ /** Allowed clock skew in seconds for the `iat` window. Defaults to {@link DEFAULT_MAX_AGE_SECONDS}. */
92
+ maxAgeSeconds?: number;
93
+ }
94
+
95
+ /** Result of verifying a DPoP proof. */
96
+ export interface DpopVerifyResult {
97
+ /** Whether the proof is valid. */
98
+ valid: boolean;
99
+ /** The verified JWT ID (`jti`) for replay detection by the caller. */
100
+ jti?: string;
101
+ /** The RFC 7638 thumbprint of the proof key (`jkt`). */
102
+ jkt?: string;
103
+ /**
104
+ * The proof's `nonce` claim, when it carried one (string only). Surfaced on
105
+ * both success and a `nonce_mismatch` so a caller enforcing the
106
+ * `DPoP-Nonce` mechanism (RFC 9449 §8/§9) can decide whether to answer with a
107
+ * `use_dpop_nonce` error and a fresh nonce.
108
+ */
109
+ nonce?: string;
110
+ /** Stable failure code (see {@link DpopFailureReason}) when `valid` is false. */
111
+ reason?: DpopFailureReason;
112
+ }
113
+
114
+ // Structural shapes for the Web Crypto algorithm parameters. We avoid the
115
+ // standard DOM lib names (EcKeyImportParams, EcdsaParams, …) because
116
+ // @cloudflare/workers-types does not declare them; these objects are accepted
117
+ // structurally by crypto.subtle.importKey / verify.
118
+ interface ImportAlg {
119
+ name: string;
120
+ namedCurve?: string;
121
+ hash?: string;
122
+ }
123
+ interface VerifyAlg {
124
+ name: string;
125
+ hash?: string;
126
+ saltLength?: number;
127
+ }
128
+
129
+ interface AlgSpec {
130
+ kty: "EC" | "RSA";
131
+ /** For EC algorithms, the curve the `alg` implies (`jwk.crv` must match it). */
132
+ expectedCrv?: string;
133
+ importParams: ImportAlg;
134
+ verifyParams: VerifyAlg;
135
+ }
136
+
137
+ const ALGS: Record<DpopAlgorithm, AlgSpec> = {
138
+ ES256: {
139
+ kty: "EC",
140
+ expectedCrv: "P-256",
141
+ importParams: { name: "ECDSA", namedCurve: "P-256" },
142
+ verifyParams: { name: "ECDSA", hash: "SHA-256" },
143
+ },
144
+ ES384: {
145
+ kty: "EC",
146
+ expectedCrv: "P-384",
147
+ importParams: { name: "ECDSA", namedCurve: "P-384" },
148
+ verifyParams: { name: "ECDSA", hash: "SHA-384" },
149
+ },
150
+ RS256: {
151
+ kty: "RSA",
152
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
153
+ verifyParams: { name: "RSASSA-PKCS1-v1_5" },
154
+ },
155
+ PS256: {
156
+ kty: "RSA",
157
+ importParams: { name: "RSA-PSS", hash: "SHA-256" },
158
+ verifyParams: { name: "RSA-PSS", saltLength: 32 },
159
+ },
160
+ };
161
+
162
+ /**
163
+ * Minimum accepted RSA modulus size in bits. Keys below this are rejected
164
+ * regardless of a valid signature: an attacker who controls an undersized
165
+ * key's private half could otherwise mint accepted proofs.
166
+ */
167
+ const MIN_RSA_KEY_BITS = 2048;
168
+
169
+ /** RSA/EC private-key JWK members; their presence means a private key was sent. */
170
+ const PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi"] as const;
171
+
172
+ const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
173
+
174
+ function fail(reason: DpopFailureReason): DpopVerifyResult {
175
+ return { valid: false, reason };
176
+ }
177
+
178
+ function base64urlToBytes(segment: string): Uint8Array {
179
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
180
+ const padded =
181
+ b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
182
+ const binary = atob(padded);
183
+ const bytes = new Uint8Array(binary.length);
184
+ for (let i = 0; i < binary.length; i++) {
185
+ bytes[i] = binary.charCodeAt(i);
186
+ }
187
+ return bytes;
188
+ }
189
+
190
+ function bytesToBase64url(bytes: Uint8Array): string {
191
+ let binary = "";
192
+ for (const byte of bytes) {
193
+ binary += String.fromCharCode(byte);
194
+ }
195
+ return btoa(binary)
196
+ .replace(/\+/g, "-")
197
+ .replace(/\//g, "_")
198
+ .replace(/=+$/, "");
199
+ }
200
+
201
+ function decodeJsonSegment(segment: string): unknown {
202
+ const text = new TextDecoder().decode(base64urlToBytes(segment));
203
+ return JSON.parse(text) as unknown;
204
+ }
205
+
206
+ function isObject(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ /**
211
+ * Bit length of an RSA modulus encoded as a base64url big-endian integer
212
+ * (the JWK `n` member). Leading zero bytes are ignored. Returns 0 if `n`
213
+ * cannot be decoded or is empty.
214
+ */
215
+ function rsaModulusBits(n: string): number {
216
+ let bytes: Uint8Array;
217
+ try {
218
+ bytes = base64urlToBytes(n);
219
+ } catch {
220
+ return 0;
221
+ }
222
+ let i = 0;
223
+ while (i < bytes.length && bytes[i] === 0) i++;
224
+ if (i >= bytes.length) return 0;
225
+ let bits = (bytes.length - i - 1) * 8;
226
+ for (let v = bytes[i]!; v > 0; v >>= 1) bits++;
227
+ return bits;
228
+ }
229
+
230
+ /**
231
+ * Normalize an HTTP URI for `htu` comparison: lowercase the scheme and host
232
+ * (the URL parser does this), drop default ports, and strip query and fragment.
233
+ * Returns `null` when the URI cannot be parsed.
234
+ */
235
+ function normalizeHtu(uri: string): string | null {
236
+ try {
237
+ const url = new URL(uri);
238
+ return `${url.protocol}//${url.host}${url.pathname}`;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ async function sha256Base64url(input: string): Promise<string> {
245
+ const digest = await crypto.subtle.digest(
246
+ "SHA-256",
247
+ new TextEncoder().encode(input),
248
+ );
249
+ return bytesToBase64url(new Uint8Array(digest));
250
+ }
251
+
252
+ /**
253
+ * Compute the RFC 7638 JWK thumbprint (base64url SHA-256 over the canonical,
254
+ * lexicographically-ordered required members). Returns `null` if the required
255
+ * members for the key type are missing.
256
+ */
257
+ async function jwkThumbprint(
258
+ jwk: Record<string, unknown>,
259
+ ): Promise<string | null> {
260
+ let canonical: string;
261
+ if (jwk.kty === "EC") {
262
+ const { crv, x, y } = jwk;
263
+ if (
264
+ typeof crv !== "string" ||
265
+ typeof x !== "string" ||
266
+ typeof y !== "string"
267
+ ) {
268
+ return null;
269
+ }
270
+ canonical = JSON.stringify({ crv, kty: "EC", x, y });
271
+ } else if (jwk.kty === "RSA") {
272
+ const { e, n } = jwk;
273
+ if (typeof e !== "string" || typeof n !== "string") {
274
+ return null;
275
+ }
276
+ canonical = JSON.stringify({ e, kty: "RSA", n });
277
+ } else {
278
+ return null;
279
+ }
280
+ return sha256Base64url(canonical);
281
+ }
282
+
283
+ /** Build a clean public-only JWK for `importKey`, dropping `alg`/`use`/`key_ops`. */
284
+ function publicJwk(
285
+ jwk: Record<string, unknown>,
286
+ kty: "EC" | "RSA",
287
+ ): JsonWebKey | null {
288
+ if (kty === "EC") {
289
+ const { crv, x, y } = jwk;
290
+ if (
291
+ typeof crv !== "string" ||
292
+ typeof x !== "string" ||
293
+ typeof y !== "string"
294
+ ) {
295
+ return null;
296
+ }
297
+ return { kty: "EC", crv, x, y };
298
+ }
299
+ const { n, e } = jwk;
300
+ if (typeof n !== "string" || typeof e !== "string") {
301
+ return null;
302
+ }
303
+ return { kty: "RSA", n, e };
304
+ }
305
+
306
+ /**
307
+ * Verify a DPoP proof JWT and, optionally, its binding to an access token.
308
+ *
309
+ * Pure async function; performs no I/O beyond Web Crypto. Never throws — every
310
+ * failure is returned as `{ valid: false, reason }` with a stable
311
+ * {@link DpopFailureReason} code.
312
+ *
313
+ * @param input - Request facts, the proof, and any expected token binding.
314
+ * @returns On success `{ valid: true, jti, jkt }`; otherwise `{ valid: false, reason }`.
315
+ */
316
+ export async function verifyDpopProof(
317
+ input: DpopVerifyInput,
318
+ ): Promise<DpopVerifyResult> {
319
+ const { proof, htm, htu } = input;
320
+ const now = input.now ?? Math.floor(Date.now() / 1000);
321
+ const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
322
+
323
+ // 1. Parse the compact JWS: exactly three non-empty base64url segments.
324
+ if (typeof proof !== "string") {
325
+ return fail("proof_malformed");
326
+ }
327
+ const segments = proof.split(".");
328
+ if (segments.length !== 3) {
329
+ return fail("proof_malformed");
330
+ }
331
+ const [headerSeg, payloadSeg, signatureSeg] = segments as [
332
+ string,
333
+ string,
334
+ string,
335
+ ];
336
+ if (
337
+ !BASE64URL_SEGMENT.test(headerSeg) ||
338
+ !BASE64URL_SEGMENT.test(payloadSeg) ||
339
+ !BASE64URL_SEGMENT.test(signatureSeg)
340
+ ) {
341
+ return fail("proof_malformed");
342
+ }
343
+
344
+ let header: unknown;
345
+ try {
346
+ header = decodeJsonSegment(headerSeg);
347
+ } catch {
348
+ return fail("header_invalid");
349
+ }
350
+ if (!isObject(header)) {
351
+ return fail("header_invalid");
352
+ }
353
+
354
+ // 2. Header checks: typ, crit, alg allow-list, public-only jwk.
355
+ if (header.typ !== "dpop+jwt") {
356
+ return fail("typ_invalid");
357
+ }
358
+ // RFC 7515 §4.1.11: reject any JWS carrying critical header parameters this
359
+ // library does not understand. It understands no extensions, so any `crit`.
360
+ if ("crit" in header) {
361
+ return fail("crit_unsupported");
362
+ }
363
+ const alg = header.alg;
364
+ const algSpec =
365
+ typeof alg === "string" ? ALGS[alg as DpopAlgorithm] : undefined;
366
+ if (!algSpec) {
367
+ return fail("alg_unsupported");
368
+ }
369
+
370
+ const jwk = header.jwk;
371
+ if (!isObject(jwk)) {
372
+ return fail("jwk_missing");
373
+ }
374
+ if (PRIVATE_JWK_MEMBERS.some((member) => member in jwk)) {
375
+ return fail("jwk_private");
376
+ }
377
+ if (jwk.kty !== algSpec.kty) {
378
+ return fail("jwk_invalid");
379
+ }
380
+ // EC: the curve must be the one the alg implies (ES256⇒P-256, …). WebCrypto
381
+ // would also reject a mismatch on import, but check it explicitly up front.
382
+ // A missing/non-string `crv` is malformed, not a mismatch — let it fall
383
+ // through to `publicJwk` below, which rejects it as `jwk_invalid`.
384
+ if (
385
+ algSpec.kty === "EC" &&
386
+ typeof jwk.crv === "string" &&
387
+ jwk.crv !== algSpec.expectedCrv
388
+ ) {
389
+ return fail("crv_mismatch");
390
+ }
391
+ // RSA: reject undersized moduli whose private half an attacker could control.
392
+ // A missing/non-string `n` is malformed, not undersized — let it fall through
393
+ // to `publicJwk` below, which rejects it as `jwk_invalid`.
394
+ if (algSpec.kty === "RSA") {
395
+ const n = jwk.n;
396
+ if (typeof n === "string" && rsaModulusBits(n) < MIN_RSA_KEY_BITS) {
397
+ return fail("rsa_key_too_small");
398
+ }
399
+ }
400
+ const importable = publicJwk(jwk, algSpec.kty);
401
+ if (importable === null) {
402
+ return fail("jwk_invalid");
403
+ }
404
+
405
+ // 3. Verify the signature over `header.payload` using the embedded jwk.
406
+ let signatureValid: boolean;
407
+ try {
408
+ const key = await crypto.subtle.importKey(
409
+ "jwk",
410
+ importable,
411
+ algSpec.importParams,
412
+ false,
413
+ ["verify"],
414
+ );
415
+ signatureValid = await crypto.subtle.verify(
416
+ algSpec.verifyParams,
417
+ key,
418
+ base64urlToBytes(signatureSeg),
419
+ new TextEncoder().encode(`${headerSeg}.${payloadSeg}`),
420
+ );
421
+ } catch {
422
+ return fail("jwk_invalid");
423
+ }
424
+ if (!signatureValid) {
425
+ return fail("signature_invalid");
426
+ }
427
+
428
+ let payload: unknown;
429
+ try {
430
+ payload = decodeJsonSegment(payloadSeg);
431
+ } catch {
432
+ return fail("payload_invalid");
433
+ }
434
+ if (!isObject(payload)) {
435
+ return fail("payload_invalid");
436
+ }
437
+
438
+ // 4. Claim checks: htm, htu, iat window, jti.
439
+ if (
440
+ typeof payload.htm !== "string" ||
441
+ payload.htm.toUpperCase() !== htm.toUpperCase()
442
+ ) {
443
+ return fail("htm_mismatch");
444
+ }
445
+
446
+ const expectedHtu = normalizeHtu(htu);
447
+ if (expectedHtu === null) {
448
+ return fail("htu_invalid");
449
+ }
450
+ const proofHtu =
451
+ typeof payload.htu === "string" ? normalizeHtu(payload.htu) : null;
452
+ if (proofHtu === null || proofHtu !== expectedHtu) {
453
+ return fail("htu_mismatch");
454
+ }
455
+
456
+ const iat = payload.iat;
457
+ if (typeof iat !== "number" || !Number.isFinite(iat)) {
458
+ return fail("iat_invalid");
459
+ }
460
+ if (iat < now - maxAgeSeconds) {
461
+ return fail("proof_expired");
462
+ }
463
+ if (iat > now + maxAgeSeconds) {
464
+ return fail("proof_future");
465
+ }
466
+
467
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) {
468
+ return fail("jti_missing");
469
+ }
470
+ const jti = payload.jti;
471
+
472
+ // Server-provided nonce (RFC 9449 §4.3 step 10): when the caller issued a
473
+ // nonce, the proof's `nonce` claim MUST equal it. Surface the proof's nonce
474
+ // either way so the caller can answer a mismatch with `use_dpop_nonce`.
475
+ const nonce = typeof payload.nonce === "string" ? payload.nonce : undefined;
476
+ if (input.expectedNonce !== undefined && nonce !== input.expectedNonce) {
477
+ return { valid: false, reason: "nonce_mismatch", nonce };
478
+ }
479
+
480
+ // Compute the thumbprint once, for both the cnf.jkt check and the result.
481
+ const jkt = await jwkThumbprint(jwk);
482
+ if (jkt === null) {
483
+ return fail("jwk_invalid");
484
+ }
485
+
486
+ // 5. Resource Server: access-token hash binding. Enforcing `ath` without the
487
+ // `cnf.jkt` binding would let a proof made for the token but signed by any
488
+ // key validate, defeating proof-of-possession (RFC 9449 §7.1), so an
489
+ // access token requires the expected thumbprint too.
490
+ if (input.accessToken !== undefined) {
491
+ if (input.expectedJkt === undefined) {
492
+ return fail("jkt_required");
493
+ }
494
+ const expectedAth = await sha256Base64url(input.accessToken);
495
+ if (typeof payload.ath !== "string" || payload.ath !== expectedAth) {
496
+ return fail("ath_mismatch");
497
+ }
498
+ }
499
+
500
+ // 6. Resource Server: token confirmation thumbprint binding.
501
+ if (input.expectedJkt !== undefined && input.expectedJkt !== jkt) {
502
+ return fail("jkt_mismatch");
503
+ }
504
+
505
+ // 7. Success.
506
+ return { valid: true, jti, jkt, nonce };
507
+ }