Skip to content
Source
Sign inSign up

DpopProofs.ts

182 lines5.9 KB

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