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}