1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no KeyObject, generateKeyPairSync, or sign.
2import * as NodeCrypto from "node:crypto";
3
4import { assert, describe, it } from "@effect/vitest";
5
6import {
7 computeDpopAccessTokenHash,
8 computeDpopJwkThumbprint,
9 normalizeDpopHtu,
10 type DpopPublicJwk,
11 verifyDpopProof,
12} from "./dpop.ts";
13
14function signDpopProof(input: {
15 readonly method: string;
16 readonly url: string;
17 readonly iat: number;
18 readonly privateKey: NodeCrypto.KeyObject;
19 readonly publicJwk: DpopPublicJwk | (DpopPublicJwk & { readonly d: string });
20 readonly accessToken?: string;
21}) {
22 const header = Buffer.from(
23 JSON.stringify({
24 typ: "dpop+jwt",
25 alg: "ES256",
26 jwk: input.publicJwk,
27 }),
28 ).toString("base64url");
29 const payload = Buffer.from(
30 JSON.stringify({
31 htm: input.method,
32 htu: input.url,
33 jti: "proof-1",
34 iat: input.iat,
35 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
36 }),
37 ).toString("base64url");
38 const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
39 key: input.privateKey,
40 dsaEncoding: "ieee-p1363",
41 }).toString("base64url");
42 return `${header}.${payload}.${signature}`;
43}
44
45describe("verifyDpopProof", () => {
46 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
47 namedCurve: "P-256",
48 });
49 const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk;
50 const proof = signDpopProof({
51 method: "POST",
52 url: "https://example.com/oauth/token",
53 iat: 100,
54 privateKey,
55 publicJwk,
56 });
57
58 it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
59 const thumbprint = computeDpopJwkThumbprint(publicJwk);
60 const result = verifyDpopProof({
61 proof,
62 method: "POST",
63 url: "https://example.com/oauth/token",
64 nowEpochSeconds: 101,
65 expectedThumbprint: thumbprint,
66 });
67
68 if (!result.ok) {
69 assert.fail(result.reason);
70 }
71 assert.equal(result.thumbprint, thumbprint);
72 assert.equal(result.jti, "proof-1");
73 });
74
75 it("rejects malformed DPoP header and payload JSON", () => {
76 const [header, payload, signature] = proof.split(".");
77 if (!header || !payload || !signature) {
78 assert.fail("Expected the test DPoP proof to use compact JWT format.");
79 }
80 const malformedJson = Buffer.from("{").toString("base64url");
81
82 const malformedHeader = verifyDpopProof({
83 proof: `${malformedJson}.${payload}.${signature}`,
84 method: "POST",
85 url: "https://example.com/oauth/token",
86 nowEpochSeconds: 101,
87 });
88 if (malformedHeader.ok) {
89 assert.fail("Expected malformed DPoP header JSON to fail.");
90 }
91 assert.equal(malformedHeader.reason, "Invalid DPoP JWT header.");
92
93 const malformedPayload = verifyDpopProof({
94 proof: `${header}.${malformedJson}.${signature}`,
95 method: "POST",
96 url: "https://example.com/oauth/token",
97 nowEpochSeconds: 101,
98 });
99 if (malformedPayload.ok) {
100 assert.fail("Expected malformed DPoP payload JSON to fail.");
101 }
102 assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload.");
103 });
104
105 it("rejects method, URL, thumbprint, and time-window mismatches", () => {
106 const thumbprint = computeDpopJwkThumbprint(publicJwk);
107 assert.equal(
108 verifyDpopProof({
109 proof,
110 method: "GET",
111 url: "https://example.com/oauth/token",
112 nowEpochSeconds: 101,
113 expectedThumbprint: thumbprint,
114 }).ok,
115 false,
116 );
117 assert.equal(
118 verifyDpopProof({
119 proof,
120 method: "POST",
121 url: "https://example.com/other",
122 nowEpochSeconds: 101,
123 expectedThumbprint: thumbprint,
124 }).ok,
125 false,
126 );
127 assert.equal(
128 verifyDpopProof({
129 proof,
130 method: "POST",
131 url: "https://example.com/oauth/token",
132 nowEpochSeconds: 101,
133 expectedThumbprint: "other-thumbprint",
134 }).ok,
135 false,
136 );
137 assert.equal(
138 verifyDpopProof({
139 proof,
140 method: "POST",
141 url: "https://example.com/oauth/token",
142 nowEpochSeconds: 1_000,
143 expectedThumbprint: thumbprint,
144 }).ok,
145 false,
146 );
147 });
148
149 it("reports a time-window failure only after the proof signature is valid", () => {
150 const thumbprint = computeDpopJwkThumbprint(publicJwk);
151 const outsideWindow = verifyDpopProof({
152 proof,
153 method: "POST",
154 url: "https://example.com/oauth/token",
155 nowEpochSeconds: 1_000,
156 expectedThumbprint: thumbprint,
157 });
158 if (outsideWindow.ok) {
159 assert.fail("Expected an old DPoP proof to fail.");
160 }
161 assert.equal(outsideWindow.code, "time_window");
162
163 const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", {
164 namedCurve: "P-256",
165 });
166 const invalidSignatureProof = signDpopProof({
167 method: "POST",
168 url: "https://example.com/oauth/token",
169 iat: 100,
170 privateKey: otherPrivateKey,
171 publicJwk,
172 });
173 const invalidSignature = verifyDpopProof({
174 proof: invalidSignatureProof,
175 method: "POST",
176 url: "https://example.com/oauth/token",
177 nowEpochSeconds: 1_000,
178 expectedThumbprint: thumbprint,
179 });
180 if (invalidSignature.ok) {
181 assert.fail("Expected a proof signed by a different key to fail.");
182 }
183 assert.equal(invalidSignature.code, "invalid_signature");
184 });
185
186 it("requires the RFC 9449 access token hash when an access token is expected", () => {
187 const thumbprint = computeDpopJwkThumbprint(publicJwk);
188 const accessTokenProof = signDpopProof({
189 method: "POST",
190 url: "https://example.com/v1/environments/env/connect",
191 iat: 100,
192 privateKey,
193 publicJwk,
194 accessToken: "clerk-access-token",
195 });
196
197 assert.equal(
198 verifyDpopProof({
199 proof: accessTokenProof,
200 method: "POST",
201 url: "https://example.com/v1/environments/env/connect",
202 nowEpochSeconds: 101,
203 expectedThumbprint: thumbprint,
204 expectedAccessToken: "clerk-access-token",
205 }).ok,
206 true,
207 );
208
209 const missingHash = verifyDpopProof({
210 proof,
211 method: "POST",
212 url: "https://example.com/oauth/token",
213 nowEpochSeconds: 101,
214 expectedThumbprint: thumbprint,
215 expectedAccessToken: "clerk-access-token",
216 });
217 if (missingHash.ok) {
218 assert.fail("Expected DPoP proof without an access token hash to fail.");
219 }
220 assert.equal(missingHash.reason, "DPoP access token hash mismatch.");
221
222 const mismatchedHash = verifyDpopProof({
223 proof: accessTokenProof,
224 method: "POST",
225 url: "https://example.com/v1/environments/env/connect",
226 nowEpochSeconds: 101,
227 expectedThumbprint: thumbprint,
228 expectedAccessToken: "other-access-token",
229 });
230 if (mismatchedHash.ok) {
231 assert.fail("Expected DPoP proof with a mismatched access token hash to fail.");
232 }
233 assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch.");
234 });
235
236 it("normalizes htu by excluding query and fragment components per RFC 9449", () => {
237 assert.equal(
238 normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),
239 "https://example.com/v1/environments/env/connect",
240 );
241
242 const thumbprint = computeDpopJwkThumbprint(publicJwk);
243 const queryProof = signDpopProof({
244 method: "POST",
245 url: "https://example.com/v1/environments/env/connect",
246 iat: 100,
247 privateKey,
248 publicJwk,
249 });
250
251 assert.equal(
252 verifyDpopProof({
253 proof: queryProof,
254 method: "POST",
255 url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
256 nowEpochSeconds: 101,
257 expectedThumbprint: thumbprint,
258 }).ok,
259 true,
260 );
261 });
262
263 it("rejects DPoP public JWK headers that expose private key material", () => {
264 const thumbprint = computeDpopJwkThumbprint(publicJwk);
265 const privateJwk = privateKey.export({ format: "jwk" }) as DpopPublicJwk & {
266 readonly d: string;
267 };
268 const proofWithPrivateJwk = signDpopProof({
269 method: "POST",
270 url: "https://example.com/oauth/token",
271 iat: 100,
272 privateKey,
273 publicJwk: privateJwk,
274 });
275
276 const result = verifyDpopProof({
277 proof: proofWithPrivateJwk,
278 method: "POST",
279 url: "https://example.com/oauth/token",
280 nowEpochSeconds: 101,
281 expectedThumbprint: thumbprint,
282 });
283
284 if (result.ok) {
285 assert.fail("Expected DPoP proof with private JWK material to fail.");
286 }
287 assert.equal(result.reason, "Invalid DPoP JWT header.");
288 });
289});