this repo has no description
1const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
3const DPOP_KEY_STORE = "tranquil_pds_dpop_keys";
4const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce";
5
6const SCOPES = [
7 "atproto",
8 "repo:*?action=create",
9 "repo:*?action=update",
10 "repo:*?action=delete",
11 "blob:*/*",
12].join(" ");
13
14const CLIENT_ID = !(import.meta.env.DEV)
15 ? `${globalThis.location.origin}/oauth/client-metadata.json`
16 : `http://localhost/?scope=${SCOPES}`;
17
18const REDIRECT_URI = `${globalThis.location.origin}/app/`;
19
20interface OAuthState {
21 state: string;
22 codeVerifier: string;
23 returnTo?: string;
24}
25
26interface DPoPKeyPair {
27 publicKey: CryptoKey;
28 privateKey: CryptoKey;
29 jwk: JsonWebKey;
30}
31
32function generateRandomString(length: number): string {
33 const array = new Uint8Array(length);
34 crypto.getRandomValues(array);
35 return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
36 "",
37 );
38}
39
40function sha256(plain: string): Promise<ArrayBuffer> {
41 const encoder = new TextEncoder();
42 const data = encoder.encode(plain);
43 return crypto.subtle.digest("SHA-256", data);
44}
45
46function base64UrlEncode(buffer: ArrayBuffer): string {
47 const bytes = new Uint8Array(buffer);
48 const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
49 "",
50 );
51 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
52 /=+$/,
53 "",
54 );
55}
56
57export async function generateCodeChallenge(verifier: string): Promise<string> {
58 const hash = await sha256(verifier);
59 return base64UrlEncode(hash);
60}
61
62export function generateState(): string {
63 return generateRandomString(32);
64}
65
66export function generateCodeVerifier(): string {
67 return generateRandomString(32);
68}
69
70export function saveOAuthState(state: OAuthState): void {
71 sessionStorage.setItem(OAUTH_STATE_KEY, state.state);
72 sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);
73}
74
75function getOAuthState(): OAuthState | null {
76 const state = sessionStorage.getItem(OAUTH_STATE_KEY);
77 const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY);
78 if (!state || !codeVerifier) return null;
79 return { state, codeVerifier };
80}
81
82function clearOAuthState(): void {
83 sessionStorage.removeItem(OAUTH_STATE_KEY);
84 sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
85}
86
87function clearDPoPNonce(): void {
88 sessionStorage.removeItem(DPOP_NONCE_KEY);
89}
90
91export function clearAllOAuthState(): void {
92 clearOAuthState();
93 clearDPoPNonce();
94}
95
96async function openKeyStore(): Promise<IDBDatabase> {
97 return new Promise((resolve, reject) => {
98 const request = indexedDB.open(DPOP_KEY_STORE, 1);
99 request.onerror = () => reject(request.error);
100 request.onsuccess = () => resolve(request.result);
101 request.onupgradeneeded = () => {
102 const db = request.result;
103 if (!db.objectStoreNames.contains("keys")) {
104 db.createObjectStore("keys");
105 }
106 };
107 });
108}
109
110async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> {
111 const db = await openKeyStore();
112 return new Promise((resolve, reject) => {
113 const tx = db.transaction("keys", "readwrite");
114 const store = tx.objectStore("keys");
115 store.put(keyPair.publicKey, "publicKey");
116 store.put(keyPair.privateKey, "privateKey");
117 store.put(keyPair.jwk, "jwk");
118 tx.oncomplete = () => {
119 db.close();
120 resolve();
121 };
122 tx.onerror = () => {
123 db.close();
124 reject(tx.error);
125 };
126 });
127}
128
129async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> {
130 try {
131 const db = await openKeyStore();
132 return new Promise((resolve, reject) => {
133 const tx = db.transaction("keys", "readonly");
134 const store = tx.objectStore("keys");
135 const publicKeyReq = store.get("publicKey");
136 const privateKeyReq = store.get("privateKey");
137 const jwkReq = store.get("jwk");
138 tx.oncomplete = () => {
139 db.close();
140 if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) {
141 resolve({
142 publicKey: publicKeyReq.result,
143 privateKey: privateKeyReq.result,
144 jwk: jwkReq.result,
145 });
146 } else {
147 resolve(null);
148 }
149 };
150 tx.onerror = () => {
151 db.close();
152 reject(tx.error);
153 };
154 });
155 } catch {
156 return null;
157 }
158}
159
160async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
161 const keyPair = await crypto.subtle.generateKey(
162 { name: "ECDSA", namedCurve: "P-256" },
163 true,
164 ["sign", "verify"],
165 );
166 const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
167 return {
168 publicKey: keyPair.publicKey,
169 privateKey: keyPair.privateKey,
170 jwk,
171 };
172}
173
174async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> {
175 const existing = await loadDPoPKeyPair();
176 if (existing) return existing;
177
178 const keyPair = await generateDPoPKeyPair();
179 await storeDPoPKeyPair(keyPair);
180 return keyPair;
181}
182
183async function createDPoPProof(
184 keyPair: DPoPKeyPair,
185 method: string,
186 url: string,
187 nonce?: string,
188 accessTokenHash?: string,
189): Promise<string> {
190 const header = {
191 typ: "dpop+jwt",
192 alg: "ES256",
193 jwk: {
194 kty: keyPair.jwk.kty,
195 crv: keyPair.jwk.crv,
196 x: keyPair.jwk.x,
197 y: keyPair.jwk.y,
198 },
199 };
200
201 const payload: Record<string, unknown> = {
202 jti: generateRandomString(16),
203 htm: method.toUpperCase(),
204 htu: url.split("?")[0],
205 iat: Math.floor(Date.now() / 1000),
206 };
207
208 if (nonce) {
209 payload.nonce = nonce;
210 }
211
212 if (accessTokenHash) {
213 payload.ath = accessTokenHash;
214 }
215
216 const headerB64 = base64UrlEncode(
217 new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer,
218 );
219 const payloadB64 = base64UrlEncode(
220 new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer,
221 );
222 const signingInput = `${headerB64}.${payloadB64}`;
223
224 const signature = await crypto.subtle.sign(
225 { name: "ECDSA", hash: "SHA-256" },
226 keyPair.privateKey,
227 new TextEncoder().encode(signingInput),
228 );
229
230 const sigBytes = new Uint8Array(signature);
231 const signatureB64 = base64UrlEncode(sigBytes.buffer);
232
233 return `${signingInput}.${signatureB64}`;
234}
235
236async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
237 const canonical = JSON.stringify({
238 crv: jwk.crv,
239 kty: jwk.kty,
240 x: jwk.x,
241 y: jwk.y,
242 });
243 const hash = await sha256(canonical);
244 return base64UrlEncode(hash);
245}
246
247function getDPoPNonce(): string | null {
248 return sessionStorage.getItem(DPOP_NONCE_KEY);
249}
250
251function setDPoPNonce(nonce: string): void {
252 sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
253}
254
255function extractDPoPNonceFromResponse(response: Response): void {
256 const nonce = response.headers.get("DPoP-Nonce");
257 if (nonce) {
258 setDPoPNonce(nonce);
259 }
260}
261
262export async function startOAuthLogin(): Promise<void> {
263 clearAllOAuthState();
264
265 const state = generateState();
266 const codeVerifier = generateCodeVerifier();
267 const codeChallenge = await generateCodeChallenge(codeVerifier);
268
269 const keyPair = await getOrCreateDPoPKeyPair();
270 const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
271
272 saveOAuthState({ state, codeVerifier });
273
274 const parResponse = await fetch("/oauth/par", {
275 method: "POST",
276 headers: { "Content-Type": "application/x-www-form-urlencoded" },
277 body: new URLSearchParams({
278 client_id: CLIENT_ID,
279 redirect_uri: REDIRECT_URI,
280 response_type: "code",
281 scope: SCOPES,
282 state: state,
283 code_challenge: codeChallenge,
284 code_challenge_method: "S256",
285 dpop_jkt: dpopJkt,
286 }),
287 });
288
289 if (!parResponse.ok) {
290 const error = await parResponse.json().catch(() => ({
291 error: "Unknown error",
292 }));
293 throw new Error(
294 error.error_description || error.error || "Failed to start OAuth flow",
295 );
296 }
297
298 const { request_uri } = await parResponse.json();
299
300 const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin);
301 authorizeUrl.searchParams.set("client_id", CLIENT_ID);
302 authorizeUrl.searchParams.set("request_uri", request_uri);
303
304 globalThis.location.href = authorizeUrl.toString();
305}
306
307export interface OAuthTokens {
308 access_token: string;
309 refresh_token?: string;
310 token_type: string;
311 expires_in?: number;
312 scope?: string;
313 sub: string;
314}
315
316async function tokenRequest(
317 params: URLSearchParams,
318 retryWithNonce = true,
319): Promise<OAuthTokens> {
320 const keyPair = await getOrCreateDPoPKeyPair();
321 const tokenEndpoint = `${globalThis.location.origin}/oauth/token`;
322
323 const dpopProof = await createDPoPProof(
324 keyPair,
325 "POST",
326 tokenEndpoint,
327 getDPoPNonce() ?? undefined,
328 );
329
330 const response = await fetch("/oauth/token", {
331 method: "POST",
332 headers: {
333 "Content-Type": "application/x-www-form-urlencoded",
334 "DPoP": dpopProof,
335 },
336 body: params,
337 });
338
339 extractDPoPNonceFromResponse(response);
340
341 if (!response.ok) {
342 const error = await response.json().catch(() => ({ error: "Unknown error" }));
343
344 if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) {
345 return tokenRequest(params, false);
346 }
347
348 throw new Error(
349 error.error_description || error.error || "Token request failed",
350 );
351 }
352
353 return response.json();
354}
355
356export async function handleOAuthCallback(
357 code: string,
358 state: string,
359): Promise<OAuthTokens> {
360 const savedState = getOAuthState();
361 if (!savedState) {
362 throw new Error("No OAuth state found. Please try logging in again.");
363 }
364
365 if (savedState.state !== state) {
366 clearOAuthState();
367 throw new Error("OAuth state mismatch. Please try logging in again.");
368 }
369
370 const params = new URLSearchParams({
371 grant_type: "authorization_code",
372 client_id: CLIENT_ID,
373 code: code,
374 redirect_uri: REDIRECT_URI,
375 code_verifier: savedState.codeVerifier,
376 });
377
378 clearOAuthState();
379
380 return tokenRequest(params);
381}
382
383export async function refreshOAuthToken(
384 refreshToken: string,
385): Promise<OAuthTokens> {
386 const params = new URLSearchParams({
387 grant_type: "refresh_token",
388 client_id: CLIENT_ID,
389 refresh_token: refreshToken,
390 });
391
392 return tokenRequest(params);
393}
394
395export function checkForOAuthCallback():
396 | { code: string; state: string }
397 | null {
398 if (globalThis.location.pathname === "/app/migrate") {
399 return null;
400 }
401
402 const params = new URLSearchParams(globalThis.location.search);
403 const code = params.get("code");
404 const state = params.get("state");
405
406 if (code && state) {
407 return { code, state };
408 }
409
410 return null;
411}
412
413export function clearOAuthCallbackParams(): void {
414 const url = new URL(globalThis.location.href);
415 url.search = "";
416 globalThis.history.replaceState({}, "", url.toString());
417}