1import {
2 computeDpopAccessTokenHash,
3 computeDpopJwkThumbprint,
4 DpopPublicJwk,
5} from "@t3tools/shared/dpop";
6import * as Crypto from "effect/Crypto";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import { importJWK, SignJWT, type JWK } from "jose";
12
13export interface BrowserDpopKey {
14 readonly privateKey: CryptoKey;
15 readonly publicJwk: DpopPublicJwk;
16 readonly thumbprint: string;
17}
18
19export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
23
24const DPOP_DATABASE_NAME = "t3code:cloud-auth";
25const DPOP_DATABASE_VERSION = 1;
26const DPOP_KEY_STORE_NAME = "keys";
27const DPOP_KEY_ID = "relay-dpop-proof-key";
28const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk);
29
30export const layer = Layer.succeed(
31 Crypto.Crypto,
32 Crypto.make({
33 randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),
34 digest: (algorithm, data) =>
35 Effect.promise(async () => {
36 const input = new Uint8Array(data.length);
37 input.set(data);
38 return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer));
39 }),
40 }),
41);
42
43function dpopError(message: string, cause?: unknown) {
44 return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45}
46
47function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {
48 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {
49 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);
50 request.addEventListener("error", () =>
51 resume(
52 Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)),
53 ),
54 );
55 request.addEventListener("upgradeneeded", () => {
56 if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) {
57 request.result.createObjectStore(DPOP_KEY_STORE_NAME);
58 }
59 });
60 request.addEventListener("success", () => resume(Effect.succeed(request.result)));
61 });
62}
63
64export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, BrowserDpopError> {
65 if (typeof indexedDB === "undefined") {
66 return Effect.succeed(null);
67 }
68 return Effect.acquireUseRelease(
69 openDpopDatabase(),
70 (database) =>
71 Effect.callback<BrowserDpopKey | null, BrowserDpopError>((resume) => {
72 const request = database
73 .transaction(DPOP_KEY_STORE_NAME, "readonly")
74 .objectStore(DPOP_KEY_STORE_NAME)
75 .get(DPOP_KEY_ID);
76 request.addEventListener("error", () =>
77 resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))),
78 );
79 request.addEventListener("success", () =>
80 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),
81 );
82 }),
83 (database) => Effect.sync(() => database.close()),
84 );
85}
86
87export function writeStoredBrowserDpopKey(
88 key: BrowserDpopKey,
89): Effect.Effect<void, BrowserDpopError> {
90 if (typeof indexedDB === "undefined") {
91 return Effect.void;
92 }
93 return Effect.acquireUseRelease(
94 openDpopDatabase(),
95 (database) =>
96 Effect.callback<void, BrowserDpopError>((resume) => {
97 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");
98 transaction.addEventListener("error", () =>
99 resume(
100 Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)),
101 ),
102 );
103 transaction.addEventListener("complete", () => resume(Effect.void));
104 transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID);
105 }),
106 (database) => Effect.sync(() => database.close()),
107 );
108}
109
110export const generateBrowserDpopKey = Effect.gen(function* () {
111 const generated = yield* Effect.tryPromise({
112 try: () =>
113 crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
114 "sign",
115 "verify",
116 ]) as Promise<CryptoKeyPair>,
117 catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),
118 });
119 const privateJwk = yield* Effect.tryPromise({
120 try: () => crypto.subtle.exportKey("jwk", generated.privateKey),
121 catch: (cause) => dpopError("Could not export DPoP private key.", cause),
122 });
123 const publicJwk = yield* Effect.tryPromise({
124 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),
125 catch: (cause) => dpopError("Could not export DPoP public key.", cause),
126 }).pipe(
127 Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),
128 Effect.mapError((cause) =>
129 cause instanceof BrowserDpopError
130 ? cause
131 : dpopError("Generated DPoP public key is invalid.", cause),
132 ),
133 );
134 const privateKey = yield* Effect.tryPromise({
135 try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,
136 catch: (cause) => dpopError("Could not import DPoP private key.", cause),
137 });
138 return {
139 privateKey,
140 publicJwk,
141 thumbprint: computeDpopJwkThumbprint(publicJwk),
142 };
143});
144
145export function createBrowserDpopProof(input: {
146 readonly method: string;
147 readonly url: string;
148 readonly accessToken?: string;
149 readonly proofKey: BrowserDpopKey;
150}): Effect.Effect<
151 { readonly proof: string; readonly thumbprint: string },
152 BrowserDpopError,
153 Crypto.Crypto
154> {
155 return Effect.gen(function* () {
156 const normalizedUrl = yield* Effect.try({
157 try: () => new URL(input.url),
158 catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause),
159 });
160 normalizedUrl.search = "";
161 normalizedUrl.hash = "";
162 const jti = yield* Crypto.Crypto.pipe(
163 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
164 Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)),
165 );
166 const proof = yield* Effect.tryPromise({
167 try: () =>
168 new SignJWT({
169 htm: input.method.toUpperCase(),
170 htu: normalizedUrl.toString(),
171 jti,
172 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
173 })
174 .setProtectedHeader({
175 typ: "dpop+jwt",
176 alg: "ES256",
177 jwk: input.proofKey.publicJwk,
178 })
179 .setIssuedAt()
180 .sign(input.proofKey.privateKey),
181 catch: (cause) => dpopError("Could not sign DPoP proof.", cause),
182 });
183 return { proof, thumbprint: input.proofKey.thumbprint };
184 });
185}