1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import { lt } from "drizzle-orm";
7
8import { DpopVerificationFailureCode, verifyDpopProof } from "@t3tools/shared/dpop";
9import * as RelayDb from "../db.ts";
10import { relayDpopProofs } from "../persistence/schema.ts";
11
12export class DpopProofReplayPersistenceError extends Schema.TaggedError<DpopProofReplayPersistenceError>()(
13 "DpopProofReplayPersistenceError",
14 {
15 operation: Schema.Literals(["consume", "prune-expired"]),
16 thumbprint: Schema.optionalKey(Schema.String),
17 jti: Schema.optionalKey(Schema.String),
18 iat: Schema.optionalKey(Schema.Number),
19 expiresBefore: Schema.optionalKey(Schema.String),
20 cause: Schema.Defect(),
21 },
22) {
23 override get message(): string {
24 return `Failed to persist DPoP proof replay state during '${this.operation}'`;
25 }
26}
27
28export const DpopProofFailureCode = Schema.Union([
29 DpopVerificationFailureCode,
30 Schema.Literal("replayed"),
31]);
32export type DpopProofFailureCode = typeof DpopProofFailureCode.Type;
33
34export class DpopProofRejected extends Schema.TaggedError<DpopProofRejected>()(
35 "DpopProofRejected",
36 {
37 code: DpopProofFailureCode,
38 },
39) {
40 override get message(): string {
41 return `DPoP proof rejected: ${this.code}`;
42 }
43}
44
45export class DpopProofReplay extends Context.Service<
46 DpopProofReplay,
47 {
48 readonly verifyAndConsume: (input: {
49 readonly proof: string | undefined;
50 readonly method: string;
51 readonly url: string;
52 readonly expectedThumbprint?: string;
53 readonly expectedAccessToken?: string;
54 readonly now: DateTime.DateTime;
55 }) => Effect.Effect<string, DpopProofRejected | DpopProofReplayPersistenceError>;
56 readonly consume: (input: {
57 readonly thumbprint: string;
58 readonly jti: string;
59 readonly iat: number;
60 readonly expiresAt: DateTime.DateTime;
61 }) => Effect.Effect<boolean, DpopProofReplayPersistenceError>;
62 readonly pruneExpired: Effect.Effect<void, DpopProofReplayPersistenceError>;
63 }
64>()("t3code-relay/auth/DpopProofs/DpopProofReplay") {}
65
66const make = Effect.gen(function* () {
67 const db = yield* RelayDb.RelayDb;
68
69 const consume: DpopProofReplay["Service"]["consume"] = Effect.fn("relay.dpop_proofs.consume")(
70 function* (input) {
71 const createdAt = DateTime.formatIso(yield* DateTime.now);
72 const inserted = yield* db
73 .insert(relayDpopProofs)
74 .values({
75 thumbprint: input.thumbprint,
76 jti: input.jti,
77 iat: input.iat,
78 expiresAt: DateTime.formatIso(input.expiresAt),
79 createdAt,
80 })
81 .onConflictDoNothing()
82 .returning({ jti: relayDpopProofs.jti })
83 .pipe(
84 Effect.mapError(
85 (cause) =>
86 new DpopProofReplayPersistenceError({
87 operation: "consume",
88 thumbprint: input.thumbprint,
89 jti: input.jti,
90 iat: input.iat,
91 cause,
92 }),
93 ),
94 );
95 return inserted.length > 0;
96 },
97 );
98
99 const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn(
100 "relay.dpop_proofs.verify_and_consume",
101 )(function* (input) {
102 yield* Effect.annotateCurrentSpan({
103 "relay.dpop.method": input.method,
104 "relay.dpop.expected_thumbprint_present": input.expectedThumbprint !== undefined,
105 "relay.dpop.expected_access_token_present": input.expectedAccessToken !== undefined,
106 });
107 const result = verifyDpopProof({
108 proof: input.proof,
109 method: input.method,
110 url: input.url,
111 nowEpochSeconds: Math.floor(input.now.epochMilliseconds / 1_000),
112 ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
113 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
114 });
115 if (!result.ok) {
116 yield* Effect.logWarning("relay dpop proof rejected", {
117 code: result.code,
118 reason: result.reason,
119 method: input.method,
120 url: input.url,
121 expectedThumbprintPresent: input.expectedThumbprint !== undefined,
122 expectedAccessTokenPresent: input.expectedAccessToken !== undefined,
123 });
124 return yield* new DpopProofRejected({
125 code: result.code,
126 });
127 }
128 const consumed = yield* consume({
129 thumbprint: result.thumbprint,
130 jti: result.jti,
131 iat: result.iat,
132 expiresAt: DateTime.add(input.now, { minutes: 5 }),
133 });
134 if (!consumed) {
135 yield* Effect.logWarning("relay dpop proof replay rejected", {
136 thumbprint: result.thumbprint,
137 jti: result.jti,
138 iat: result.iat,
139 });
140 return yield* new DpopProofRejected({
141 code: "replayed",
142 });
143 }
144 yield* Effect.annotateCurrentSpan({
145 "relay.dpop.thumbprint": result.thumbprint,
146 "relay.dpop.iat": result.iat,
147 });
148 return result.thumbprint;
149 });
150
151 const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () {
152 const now = DateTime.formatIso(yield* DateTime.now);
153 yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now });
154 yield* db
155 .delete(relayDpopProofs)
156 .where(lt(relayDpopProofs.expiresAt, now))
157 .pipe(
158 Effect.mapError(
159 (cause) =>
160 new DpopProofReplayPersistenceError({
161 operation: "prune-expired",
162 expiresBefore: now,
163 cause,
164 }),
165 ),
166 );
167 }).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired"));
168
169 return DpopProofReplay.of({
170 verifyAndConsume,
171 consume,
172 pruneExpired,
173 });
174});
175
176export const layer = Layer.effect(DpopProofReplay, make);