Skip to content
Source
Sign inSign up
Open History to see the last commit

DpopProofs.verifyAndConsume.test.ts

238 lines7.6 KB

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;
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 };
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: () => {
75 return insert(values);
76 },
77 }),
78 }),
79 };
80 },
81 } as unknown as RelayDb.RelayDb["Service"];
82 return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)));
84​
85function consumeEachProofOnce() {
86 const consumed = new Map<string, string>();
87 return {
88 insert: (values: DpopProofInsertValues) =>
89 Effect.sync(() => {
90 const key = `${values.thumbprint}:${values.jti}`;
91 if (consumed.has(key)) {
92 return [];
93 }
94 consumed.set(key, values.expiresAt);
95 return [{ jti: values.jti }];
96 }),
97 pruneExpired: (now: DateTime.DateTime) => {
98 const cutoff = DateTime.formatIso(now);
99 for (const [key, expiresAt] of consumed) {
100 if (expiresAt < cutoff) {
101 consumed.delete(key);
102 }
103 }
104 },
105 };
107​
108describe("DpopProofReplay.verifyAndConsume", () => {
109 it.effect("reports a signed proof outside the time window", () => {
110 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
111 const proof = makeDpopProof({
112 method: "POST",
113 url: "https://relay.example.com/v1/environments/env/connect",
114 iat: Math.floor(now.epochMilliseconds / 1_000) - 301,
115 jti: "proof-old",
116 });
117​
118 return Effect.gen(function* () {
119 const replay = yield* DpopProofs.DpopProofReplay;
120 const error = yield* Effect.flip(
121 replay.verifyAndConsume({
122 proof: proof.proof,
123 method: "POST",
124 url: "https://relay.example.com/v1/environments/env/connect",
125 expectedThumbprint: proof.thumbprint,
126 now,
127 }),
128 );
129​
130 expect(error).toMatchObject({
131 _tag: "DpopProofRejected",
132 code: "time_window",
133 });
134 }).pipe(Effect.provide(layer(() => Effect.die("unexpected replay persistence"))));
135 });
136​
137 it.effect("rejects replays until a future proof's time window closes", () => {
138 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
139 const proof = makeDpopProof({
140 method: "POST",
141 url: "https://relay.example.com/v1/environments/env/connect",
142 iat: Math.floor(now.epochMilliseconds / 1_000) + 300,
143 jti: "proof-future",
144 });
145 const persistence = consumeEachProofOnce();
146 const input = {
147 proof: proof.proof,
148 method: "POST",
149 url: "https://relay.example.com/v1/environments/env/connect",
150 expectedThumbprint: proof.thumbprint,
151 now,
152 };
153​
154 return Effect.gen(function* () {
155 const replay = yield* DpopProofs.DpopProofReplay;
156 expect(yield* replay.verifyAndConsume(input)).toBe(proof.thumbprint);
157​
158 for (const { elapsedMs, code } of [
159 { elapsedMs: 0, code: "replayed" },
160 { elapsedMs: 301_000, code: "replayed" },
161 { elapsedMs: 600_999, code: "replayed" },
162 { elapsedMs: 601_000, code: "time_window" },
163 ]) {
164 const retryAt = DateTime.makeUnsafe(now.epochMilliseconds + elapsedMs);
165 persistence.pruneExpired(retryAt);
166 const result = yield* Effect.result(replay.verifyAndConsume({ ...input, now: retryAt }));
167​
168 expect(result, `Replay after ${elapsedMs}ms`).toMatchObject({
169 _tag: "Failure",
170 failure: { _tag: "DpopProofRejected", code },
171 });
172 }
173 }).pipe(Effect.provide(layer(persistence.insert)));
174 });
175​
176 it.effect("rejects proofs missing the expected access token hash", () => {
177 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
178 const proof = makeDpopProof({
179 method: "POST",
180 url: "https://relay.example.com/v1/environments/env/connect",
181 iat: Math.floor(now.epochMilliseconds / 1_000),
182 jti: "proof-1",
183 });
184​
185 return Effect.gen(function* () {
186 const replay = yield* DpopProofs.DpopProofReplay;
187 const error = yield* Effect.flip(
188 replay.verifyAndConsume({
189 proof: proof.proof,
190 method: "POST",
191 url: "https://relay.example.com/v1/environments/env/connect",
192 expectedThumbprint: proof.thumbprint,
193 expectedAccessToken: "clerk-access-token",
194 now,
195 }),
196 );
197​
198 expect(error).toMatchObject({
199 _tag: "DpopProofRejected",
200 code: "access_token_hash_mismatch",
201 });
202 }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence"))));
203 });
204​
205 it.effect("accepts proofs bound to the access token hash", () => {
206 const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z");
207 const proof = makeDpopProof({
208 method: "POST",
209 url: "https://relay.example.com/v1/environments/env/status",
210 iat: Math.floor(now.epochMilliseconds / 1_000),
211 jti: "proof-status-1",
212 accessToken: "clerk-access-token",
213 });
214​
215 return Effect.gen(function* () {
216 const replay = yield* DpopProofs.DpopProofReplay;
217 const thumbprint = yield* replay.verifyAndConsume({
218 proof: proof.proof,
219 method: "POST",
220 url: "https://relay.example.com/v1/environments/env/status",
221 expectedAccessToken: "clerk-access-token",
222 now,
223 });
224 const second = yield* Effect.exit(
225 replay.verifyAndConsume({
226 proof: proof.proof,
227 method: "POST",
228 url: "https://relay.example.com/v1/environments/env/status",
229 expectedAccessToken: "clerk-access-token",
230 now,
231 }),
232 );
233​
234 expect(thumbprint).toBe(proof.thumbprint);
235 expect(second._tag).toBe("Failure");
236 }).pipe(Effect.provide(layer(consumeEachProofOnce().insert)));
237 });
238});