@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 +15 -0
- package/README.md +90 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/src/index.ts +507 -0
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)
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|