1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
2import * as NodeCrypto from "node:crypto";
3
4import { describe, expect, it } from "@effect/vitest";
5import {
6 computeDpopAccessTokenHash,
7 computeDpopJwkThumbprint,
8 type DpopPublicJwk,
9} from "@t3tools/shared/dpop";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13
14import * as RelayDb from "../db.ts";
15import { relayDpopProofs } from "../persistence/schema.ts";
16import * as DpopProofs from "./DpopProofs.ts";
17
18interface DpopProofInsertValues {
19 readonly thumbprint: string;
20 readonly jti: string;
21 readonly iat: number;
22 readonly expiresAt: string;
23 readonly createdAt: string;
24}
25
26function makeDpopProof(input: {
27 readonly method: string;
28 readonly url: string;
29 readonly iat: number;
30 readonly jti: string;
31 readonly accessToken?: string;
32}) {
33 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
34 namedCurve: "P-256",
35 });
36 const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk;
37 const header = Buffer.from(
38 JSON.stringify({
39 typ: "dpop+jwt",
40 alg: "ES256",
41 jwk: publicJwk,
42 }),
43 ).toString("base64url");
44 const payload = Buffer.from(
45 JSON.stringify({
46 htm: input.method,
47 htu: input.url,
48 jti: input.jti,
49 iat: input.iat,
50 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
51 }),
52 ).toString("base64url");
53 const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
54 key: privateKey,
55 dsaEncoding: "ieee-p1363",
56 }).toString("base64url");
57 return {
58 proof: `${header}.${payload}.${signature}`,
59 thumbprint: computeDpopJwkThumbprint(publicJwk),
60 };
61}
62
63function layer(
64 insert: (
65 values: DpopProofInsertValues,
66 ) => Effect.Effect<ReadonlyArray<{ readonly jti: string }>, { _tag: string }>,
67) {
68 const fakeDb = {
69 insert: (table: unknown) => {
70 expect(table).toBe(relayDpopProofs);
71 return {
72 values: (values: DpopProofInsertValues) => ({
73 onConflictDoNothing: () => ({
74 returning: (selection: unknown) => {
75 expect(selection).toBeDefined();
76 return insert(values);
77 },
78 }),
79 }),
80 };
81 },
82 } as unknown as RelayDb.RelayDb["Service"];
83 return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)));
84}
85
86function consumeEachProofOnce() {
87 const consumed = new Set<string>();
88 return (values: DpopProofInsertValues) =>
89 Effect.sync(() => {
90 const key = `${values.thumbprint}:${values.jti}`;
91 if (consumed.has(key)) {
92 return [];
93 }
94 consumed.add(key);
95 return [{ jti: values.jti }];
96 });
97}
98
99describe("DpopProofReplay.verifyAndConsume", () => {
100 it.effect("reports a signed proof outside the time window", () => {
101 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
102 const proof = makeDpopProof({
103 method: "POST",
104 url: "https://relay.example.com/v1/environments/env/connect",
105 iat: Math.floor(now.epochMilliseconds / 1_000) - 301,
106 jti: "proof-old",
107 });
108
109 return Effect.gen(function* () {
110 const replay = yield* DpopProofs.DpopProofReplay;
111 const error = yield* Effect.flip(
112 replay.verifyAndConsume({
113 proof: proof.proof,
114 method: "POST",
115 url: "https://relay.example.com/v1/environments/env/connect",
116 expectedThumbprint: proof.thumbprint,
117 now,
118 }),
119 );
120
121 expect(error).toMatchObject({
122 _tag: "DpopProofRejected",
123 code: "time_window",
124 });
125 }).pipe(Effect.provide(layer(() => Effect.die("unexpected replay persistence"))));
126 });
127
128 it.effect("rejects replayed proofs after persistence consumes the jti once", () => {
129 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
130 const proof = makeDpopProof({
131 method: "POST",
132 url: "https://relay.example.com/v1/environments/env/connect",
133 iat: Math.floor(now.epochMilliseconds / 1_000),
134 jti: "proof-1",
135 });
136
137 return Effect.gen(function* () {
138 const replay = yield* DpopProofs.DpopProofReplay;
139 const first = yield* replay.verifyAndConsume({
140 proof: proof.proof,
141 method: "POST",
142 url: "https://relay.example.com/v1/environments/env/connect",
143 expectedThumbprint: proof.thumbprint,
144 now,
145 });
146 const second = yield* Effect.flip(
147 replay.verifyAndConsume({
148 proof: proof.proof,
149 method: "POST",
150 url: "https://relay.example.com/v1/environments/env/connect",
151 expectedThumbprint: proof.thumbprint,
152 now,
153 }),
154 );
155
156 expect(first).toBe(proof.thumbprint);
157 expect(second).toMatchObject({ _tag: "DpopProofRejected", code: "replayed" });
158 }).pipe(Effect.provide(layer(consumeEachProofOnce())));
159 });
160
161 it.effect("rejects proofs missing the expected access token hash", () => {
162 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
163 const proof = makeDpopProof({
164 method: "POST",
165 url: "https://relay.example.com/v1/environments/env/connect",
166 iat: Math.floor(now.epochMilliseconds / 1_000),
167 jti: "proof-1",
168 });
169
170 return Effect.gen(function* () {
171 const replay = yield* DpopProofs.DpopProofReplay;
172 const error = yield* Effect.flip(
173 replay.verifyAndConsume({
174 proof: proof.proof,
175 method: "POST",
176 url: "https://relay.example.com/v1/environments/env/connect",
177 expectedThumbprint: proof.thumbprint,
178 expectedAccessToken: "clerk-access-token",
179 now,
180 }),
181 );
182
183 expect(error).toMatchObject({
184 _tag: "DpopProofRejected",
185 code: "access_token_hash_mismatch",
186 });
187 }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));
188 });
189
190 it.effect("preserves replay persistence failures", () => {
191 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
192 const proof = makeDpopProof({
193 method: "POST",
194 url: "https://relay.example.com/v1/environments/env/connect",
195 iat: Math.floor(now.epochMilliseconds / 1_000),
196 jti: "proof-persistence-failure",
197 });
198 const cause = { _tag: "DatabaseUnavailable" } as const;
199
200 return Effect.gen(function* () {
201 const replay = yield* DpopProofs.DpopProofReplay;
202 const error = yield* Effect.flip(
203 replay.verifyAndConsume({
204 proof: proof.proof,
205 method: "POST",
206 url: "https://relay.example.com/v1/environments/env/connect",
207 expectedThumbprint: proof.thumbprint,
208 now,
209 }),
210 );
211
212 expect(error).toMatchObject({
213 _tag: "DpopProofReplayPersistenceError",
214 operation: "consume",
215 thumbprint: proof.thumbprint,
216 jti: "proof-persistence-failure",
217 iat: Math.floor(now.epochMilliseconds / 1_000),
218 });
219 expect(error.cause).toBe(cause);
220 expect(error).not.toHaveProperty("proof");
221 }).pipe(Effect.provide(layer(() => Effect.fail(cause))));
222 });
223
224 it.effect("accepts proofs bound to the access token hash", () => {
225 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
226 const proof = makeDpopProof({
227 method: "POST",
228 url: "https://relay.example.com/v1/environments/env/status",
229 iat: Math.floor(now.epochMilliseconds / 1_000),
230 jti: "proof-status-1",
231 accessToken: "clerk-access-token",
232 });
233
234 return Effect.gen(function* () {
235 const replay = yield* DpopProofs.DpopProofReplay;
236 const thumbprint = yield* replay.verifyAndConsume({
237 proof: proof.proof,
238 method: "POST",
239 url: "https://relay.example.com/v1/environments/env/status",
240 expectedAccessToken: "clerk-access-token",
241 now,
242 });
243 const second = yield* Effect.exit(
244 replay.verifyAndConsume({
245 proof: proof.proof,
246 method: "POST",
247 url: "https://relay.example.com/v1/environments/env/status",
248 expectedAccessToken: "clerk-access-token",
249 now,
250 }),
251 );
252
253 expect(thumbprint).toBe(proof.thumbprint);
254 expect(second._tag).toBe("Failure");
255 }).pipe(Effect.provide(layer(consumeEachProofOnce())));
256 });
257});