1import {
2 type DpopVerificationFailureCode as DpopVerificationFailureCodeType,
3 verifyDpopProof,
4} from "@t3tools/shared/dpop";
5import type { DpopFailureReason } from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9import * as Base64Url from "effect/encoding/Base64Url";
10import * as Option from "effect/Option";
11import * as HttpServerRequest from "effect/http/HttpServerRequest";
12
13import {
14 ServerAuthDpopReplayKeyCalculationError,
15 ServerAuthDpopReplayStateRecordError,
16 ServerAuthInvalidCredentialError,
17 type ServerAuthInternalError,
18} from "./EnvironmentAuth.ts";
19import * as ServerSecretStore from "./ServerSecretStore.ts";
20
21/** Secret store name prefix of DPoP replay markers. The server prunes expired ones. */
22export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-";
23
24export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => {
25 switch (code) {
26 case "time_window":
27 return "time_window";
28 case "key_mismatch":
29 return "key_mismatch";
30 case "method_mismatch":
31 case "url_mismatch":
32 return "request_mismatch";
33 case "access_token_hash_mismatch":
34 return "token_mismatch";
35 case "missing_proof":
36 case "malformed_proof":
37 case "invalid_signature":
38 case "invalid_proof":
39 return "invalid_proof";
40 }
41};
42
43export const mapDpopReplayStoreError = (
44 error: ServerSecretStore.SecretStoreError,
45): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
46 ServerSecretStore.isSecretAlreadyExistsError(error)
47 ? new ServerAuthInvalidCredentialError({
48 diagnostic: "DPoP proof replayed.",
49 dpopFailureReason: "replay",
50 cause: error,
51 })
52 : new ServerAuthDpopReplayStateRecordError({
53 cause: error,
54 });
55
56export const verifyRequestDpopProof = (input: {
57 readonly request: HttpServerRequest.HttpServerRequest;
58 readonly expectedThumbprint?: string;
59 readonly expectedAccessToken?: string;
60}) =>
61 Effect.gen(function* () {
62 const proof = input.request.headers.dpop;
63 const url = HttpServerRequest.toURL(input.request);
64 if (Option.isNone(url)) {
65 return yield* new ServerAuthInvalidCredentialError({
66 diagnostic: "Invalid DPoP request URL.",
67 });
68 }
69 const now = yield* DateTime.now;
70 const result = verifyDpopProof({
71 proof,
72 method: input.request.method,
73 url: url.value.href,
74 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
75 ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
76 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
77 });
78 if (!result.ok) {
79 yield* Effect.annotateCurrentSpan({
80 "environment.dpop.failure_code": result.code,
81 });
82 return yield* new ServerAuthInvalidCredentialError({
83 diagnostic: result.reason,
84 dpopFailureReason: mapDpopFailureReason(result.code),
85 });
86 }
87 const secretStore = yield* ServerSecretStore.ServerSecretStore;
88 const replayKey = yield* Crypto.Crypto.pipe(
89 Effect.flatMap((crypto) =>
90 crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)),
91 ),
92 Effect.map(Base64Url.encode),
93 Effect.mapError(
94 (cause) =>
95 new ServerAuthDpopReplayKeyCalculationError({
96 cause,
97 }),
98 ),
99 );
100 yield* secretStore
101 .create(
102 `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`,
103 new TextEncoder().encode(
104 [
105 `thumbprint=${result.thumbprint}`,
106 `jti=${result.jti}`,
107 `iat=${result.iat}`,
108 `consumedAt=${DateTime.formatIso(now)}`,
109 ].join("\n"),
110 ),
111 )
112 .pipe(
113 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
114 Effect.gen(function* () {
115 const mapped = mapDpopReplayStoreError(error);
116 if (mapped._tag === "ServerAuthInvalidCredentialError") {
117 yield* Effect.annotateCurrentSpan({
118 "environment.dpop.failure_code": mapped.dpopFailureReason,
119 });
120 }
121 return yield* mapped;
122 }),
123 ),
124 );
125 return result.thumbprint;
126 });