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";
5import { vi } from "vite-plus/test";
6
7import {
8 computeDpopAccessTokenHash,
9 computeDpopJwkThumbprint,
10 normalizeDpopHtu,
11 type DpopPublicJwk,
12 verifyDpopProof,
13} from "./dpop.ts";
14
15function signDpopProof(input: {
16 readonly method: string;
17 readonly url: string;
18 readonly iat: number;
19 readonly privateKey: NodeCrypto.KeyObject;
20 readonly publicJwk: DpopPublicJwk | (DpopPublicJwk & { readonly d: string });
21 readonly accessToken?: string;
22}) {
23 const header = Buffer.from(
24 JSON.stringify({
25 typ: "dpop+jwt",
26 alg: "ES256",
27 jwk: input.publicJwk,
28 }),
29 ).toString("base64url");
30 const payload = Buffer.from(
31 JSON.stringify({
32 htm: input.method,
33 htu: input.url,
34 jti: "proof-1",
35 iat: input.iat,
36 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
37 }),
38 ).toString("base64url");
39 const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
40 key: input.privateKey,
41 dsaEncoding: "ieee-p1363",
42 }).toString("base64url");
43 return `${header}.${payload}.${signature}`;
44}
45
46describe("verifyDpopProof", () => {
47 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
48 namedCurve: "P-256",
49 });
50 const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk;
51 const proof = signDpopProof({
52 method: "POST",
53 url: "https://example.com/oauth/token",
54 iat: 100,
55 privateKey,
56 publicJwk,
57 });
58
59 it("computes the RFC 7638 thumbprint whatever the host locale", () => {
60 // Latvian collation sorts "y" before "kty", which broke every remote client.
61 const latvian = new Intl.Collator("lv");
62 const localeCompare = vi.spyOn(String.prototype, "localeCompare").mockImplementation(function (
63 this: string,
64 that: string,
65 ) {
66 return latvian.compare(this, that);
67 });
68 try {
69 const rfc7638Input = JSON.stringify({
70 crv: publicJwk.crv,
71 kty: publicJwk.kty,
72 x: publicJwk.x,
73 y: publicJwk.y,
74 });
75 assert.equal(
76 computeDpopJwkThumbprint(publicJwk),
77 NodeCrypto.createHash("sha256").update(rfc7638Input).digest("base64url"),
78 );
79 } finally {
80 localeCompare.mockRestore();
81 }
82 });
83
84 it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
85 const thumbprint = computeDpopJwkThumbprint(publicJwk);
86 const result = verifyDpopProof({
87 proof,
88 method: "POST",
89 url: "https://example.com/oauth/token",
90 nowEpochSeconds: 101,
91 expectedThumbprint: thumbprint,
92 });
93
94 if (!result.ok) {
95 assert.fail(result.reason);
96 }
97 assert.equal(result.thumbprint, thumbprint);
98 assert.equal(result.jti, "proof-1");
99 });
100
101 it("rejects malformed DPoP header and payload JSON", () => {
102 const [header, payload, signature] = proof.split(".");
103 if (!header || !payload || !signature) {
104 assert.fail("Expected the test DPoP proof to use compact JWT format.");
105 }
106 const malformedJson = Buffer.from("{").toString("base64url");
107
108 const malformedHeader = verifyDpopProof({
109 proof: `${malformedJson}.${payload}.${signature}`,
110 method: "POST",
111 url: "https://example.com/oauth/token",
112 nowEpochSeconds: 101,
113 });
114 if (malformedHeader.ok) {
115 assert.fail("Expected malformed DPoP header JSON to fail.");
116 }
117 assert.equal(malformedHeader.reason, "Invalid DPoP JWT header.");
118
119 const malformedPayload = verifyDpopProof({
120 proof: `${header}.${malformedJson}.${signature}`,
121 method: "POST",
122 url: "https://example.com/oauth/token",
123 nowEpochSeconds: 101,
124 });
125 if (malformedPayload.ok) {
126 assert.fail("Expected malformed DPoP payload JSON to fail.");
127 }
128 assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload.");
129 });
130
131 it("rejects method, URL, thumbprint, and time-window mismatches", () => {
132 const thumbprint = computeDpopJwkThumbprint(publicJwk);
133 assert.equal(
134 verifyDpopProof({
135 proof,
136 method: "GET",
137 url: "https://example.com/oauth/token",
138 nowEpochSeconds: 101,
139 expectedThumbprint: thumbprint,
140 }).ok,
141 false,
142 );
143 assert.equal(
144 verifyDpopProof({
145 proof,
146 method: "POST",
147 url: "https://example.com/other",
148 nowEpochSeconds: 101,
149 expectedThumbprint: thumbprint,
150 }).ok,
151 false,
152 );
153 assert.equal(
154 verifyDpopProof({
155 proof,
156 method: "POST",
157 url: "https://example.com/oauth/token",
158 nowEpochSeconds: 101,
159 expectedThumbprint: "other-thumbprint",
160 }).ok,
161 false,
162 );
163 assert.equal(
164 verifyDpopProof({
165 proof,
166 method: "POST",
167 url: "https://example.com/oauth/token",
168 nowEpochSeconds: 1_000,
169 expectedThumbprint: thumbprint,
170 }).ok,
171 false,
172 );
173 });
174
175 it("reports a time-window failure only after the proof signature is valid", () => {
176 const thumbprint = computeDpopJwkThumbprint(publicJwk);
177 const outsideWindow = verifyDpopProof({
178 proof,
179 method: "POST",
180 url: "https://example.com/oauth/token",
181 nowEpochSeconds: 1_000,
182 expectedThumbprint: thumbprint,
183 });
184 if (outsideWindow.ok) {
185 assert.fail("Expected an old DPoP proof to fail.");
186 }
187 assert.equal(outsideWindow.code, "time_window");
188
189 const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", {
190 namedCurve: "P-256",
191 });
192 const invalidSignatureProof = signDpopProof({
193 method: "POST",
194 url: "https://example.com/oauth/token",
195 iat: 100,
196 privateKey: otherPrivateKey,
197 publicJwk,
198 });
199 const invalidSignature = verifyDpopProof({
200 proof: invalidSignatureProof,
201 method: "POST",
202 url: "https://example.com/oauth/token",
203 nowEpochSeconds: 1_000,
204 expectedThumbprint: thumbprint,
205 });
206 if (invalidSignature.ok) {
207 assert.fail("Expected a proof signed by a different key to fail.");
208 }
209 assert.equal(invalidSignature.code, "invalid_signature");
210 });
211
212 it("accepts clock offsets through 300 seconds in either direction", () => {
213 for (const { offset, accepted } of [
214 { offset: -300, accepted: true },
215 { offset: 300, accepted: true },
216 { offset: -301, accepted: false },
217 { offset: 301, accepted: false },
218 ]) {
219 const result = verifyDpopProof({
220 proof,
221 method: "POST",
222 url: "https://example.com/oauth/token",
223 nowEpochSeconds: 100 + offset,
224 });
225
226 assert.equal(result.ok, accepted, `Clock offset: ${offset}s`);
227 if (!result.ok) {
228 assert.equal(result.code, "time_window");
229 }
230 }
231 });
232
233 it("requires the RFC 9449 access token hash when an access token is expected", () => {
234 const thumbprint = computeDpopJwkThumbprint(publicJwk);
235 const accessTokenProof = signDpopProof({
236 method: "POST",
237 url: "https://example.com/v1/environments/env/connect",
238 iat: 100,
239 privateKey,
240 publicJwk,
241 accessToken: "clerk-access-token",
242 });
243
244 assert.equal(
245 verifyDpopProof({
246 proof: accessTokenProof,
247 method: "POST",
248 url: "https://example.com/v1/environments/env/connect",
249 nowEpochSeconds: 101,
250 expectedThumbprint: thumbprint,
251 expectedAccessToken: "clerk-access-token",
252 }).ok,
253 true,
254 );
255
256 const missingHash = verifyDpopProof({
257 proof,
258 method: "POST",
259 url: "https://example.com/oauth/token",
260 nowEpochSeconds: 101,
261 expectedThumbprint: thumbprint,
262 expectedAccessToken: "clerk-access-token",
263 });
264 if (missingHash.ok) {
265 assert.fail("Expected DPoP proof without an access token hash to fail.");
266 }
267 assert.equal(missingHash.reason, "DPoP access token hash mismatch.");
268
269 const mismatchedHash = verifyDpopProof({
270 proof: accessTokenProof,
271 method: "POST",
272 url: "https://example.com/v1/environments/env/connect",
273 nowEpochSeconds: 101,
274 expectedThumbprint: thumbprint,
275 expectedAccessToken: "other-access-token",
276 });
277 if (mismatchedHash.ok) {
278 assert.fail("Expected DPoP proof with a mismatched access token hash to fail.");
279 }
280 assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch.");
281 });
282
283 it("normalizes htu by excluding query and fragment components per RFC 9449", () => {
284 assert.equal(
285 normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),
286 "https://example.com/v1/environments/env/connect",
287 );
288
289 const thumbprint = computeDpopJwkThumbprint(publicJwk);
290 const queryProof = signDpopProof({
291 method: "POST",
292 url: "https://example.com/v1/environments/env/connect",
293 iat: 100,
294 privateKey,
295 publicJwk,
296 });
297
298 assert.equal(
299 verifyDpopProof({
300 proof: queryProof,
301 method: "POST",
302 url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
303 nowEpochSeconds: 101,
304 expectedThumbprint: thumbprint,
305 }).ok,
306 true,
307 );
308 });
309
310 it("rejects DPoP public JWK headers that expose private key material", () => {
311 const thumbprint = computeDpopJwkThumbprint(publicJwk);
312 const privateJwk = privateKey.export({ format: "jwk" }) as DpopPublicJwk & {
313 readonly d: string;
314 };
315 const proofWithPrivateJwk = signDpopProof({
316 method: "POST",
317 url: "https://example.com/oauth/token",
318 iat: 100,
319 privateKey,
320 publicJwk: privateJwk,
321 });
322
323 const result = verifyDpopProof({
324 proof: proofWithPrivateJwk,
325 method: "POST",
326 url: "https://example.com/oauth/token",
327 nowEpochSeconds: 101,
328 expectedThumbprint: thumbprint,
329 });
330
331 if (result.ok) {
332 assert.fail("Expected DPoP proof with private JWK material to fail.");
333 }
334 assert.equal(result.reason, "Invalid DPoP JWT header.");
335 });
336});