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