1import {
2 type DpopVerificationFailureCode as DpopVerificationFailureCodeType,
3 verifyDpopProof,
4} from "@t3tools/shared/dpop";
5import type { DpopFailureReason } from "@t3tools/contracts";
6import * as Digest from "effect/crypto/Digest";
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";
20import { requestUrl } from "../requestUrl.ts";
21
22export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-";
23
24const 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 = Effect.fn("dpop.verifyRequestDpopProof")(function* (input: {
57 readonly request: HttpServerRequest.HttpServerRequest;
58 /** Overrides the `DPoP` header for requests that cannot carry one, such as a WebSocket upgrade. */
59 readonly proof?: string;
60 readonly expectedThumbprint?: string;
61 readonly expectedAccessToken?: string;
62}) {
63 const proof = input.proof ?? input.request.headers.dpop;
64 const url = requestUrl(input.request);
65 if (Option.isNone(url)) {
66 return yield* new ServerAuthInvalidCredentialError({
67 diagnostic: "Invalid DPoP request URL.",
68 });
69 }
70 const now = yield* DateTime.now;
71 const result = verifyDpopProof({
72 proof,
73 method: input.request.method,
74 url: url.value.href,
75 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
76 ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
77 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
78 });
79 if (!result.ok) {
80 yield* Effect.annotateCurrentSpan({
81 "environment.dpop.failure_code": result.code,
82 });
83 return yield* new ServerAuthInvalidCredentialError({
84 diagnostic: result.reason,
85 dpopFailureReason: mapDpopFailureReason(result.code),
86 });
87 }
88 const secretStore = yield* ServerSecretStore.ServerSecretStore;
89 const replayKey = yield* Digest.Digest.pipe(
90 Effect.flatMap((digest) =>
91 digest.digest(Digest.sha256, new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)),
92 ),
93 Effect.map(Base64Url.encode),
94 Effect.mapError(
95 (cause) =>
96 new ServerAuthDpopReplayKeyCalculationError({
97 cause,
98 }),
99 ),
100 );
101 yield* secretStore
102 .create(
103 `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`,
104 new TextEncoder().encode(
105 [
106 `thumbprint=${result.thumbprint}`,
107 `jti=${result.jti}`,
108 `iat=${result.iat}`,
109 `consumedAt=${DateTime.formatIso(now)}`,
110 ].join("\n"),
111 ),
112 )
113 .pipe(
114 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
115 Effect.gen(function* () {
116 const mapped = mapDpopReplayStoreError(error);
117 if (mapped._tag === "ServerAuthInvalidCredentialError") {
118 yield* Effect.annotateCurrentSpan({
119 "environment.dpop.failure_code": mapped.dpopFailureReason,
120 });
121 }
122 return yield* mapped;
123 }),
124 ),
125 );
126 return result.thumbprint;
127});