this repo has no description
1import { 2 api, 3 ApiError, 4 type CreateAccountParams, 5 type CreateAccountResult, 6 typedApi, 7} from "./api.ts"; 8import type { Session } from "./types/api.ts"; 9import { 10 type AccessToken, 11 type Did, 12 type Handle, 13 type RefreshToken, 14 unsafeAsAccessToken, 15 unsafeAsDid, 16 unsafeAsHandle, 17 unsafeAsRefreshToken, 18} from "./types/branded.ts"; 19import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; 20import { assertNever } from "./types/exhaustive.ts"; 21import { 22 checkForOAuthCallback, 23 clearAllOAuthState, 24 clearOAuthCallbackParams, 25 handleOAuthCallback, 26 refreshOAuthToken, 27 startOAuthLogin, 28} from "./oauth.ts"; 29import { setLocale, type SupportedLocale } from "./i18n.ts"; 30 31const STORAGE_KEY = "tranquil_pds_session"; 32const ACCOUNTS_KEY = "tranquil_pds_accounts"; 33 34export interface SavedAccount { 35 readonly did: Did; 36 readonly handle: Handle; 37 readonly accessJwt: AccessToken; 38 readonly refreshJwt: RefreshToken; 39} 40 41export type AuthError = 42 | { readonly type: "network"; readonly message: string } 43 | { readonly type: "unauthorized"; readonly message: string } 44 | { readonly type: "validation"; readonly message: string } 45 | { readonly type: "oauth"; readonly message: string } 46 | { readonly type: "unknown"; readonly message: string }; 47 48function toAuthError(e: unknown): AuthError { 49 if (e instanceof ApiError) { 50 if (e.status === 401) { 51 return { type: "unauthorized", message: e.message }; 52 } 53 return { type: "validation", message: e.message }; 54 } 55 if (e instanceof Error) { 56 if (e.message.includes("network") || e.message.includes("fetch")) { 57 return { type: "network", message: e.message }; 58 } 59 return { type: "unknown", message: e.message }; 60 } 61 return { type: "unknown", message: "An unknown error occurred" }; 62} 63 64type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error"; 65 66export type AuthState = 67 | { 68 readonly kind: "unauthenticated"; 69 readonly savedAccounts: readonly SavedAccount[]; 70 } 71 | { 72 readonly kind: "loading"; 73 readonly savedAccounts: readonly SavedAccount[]; 74 readonly previousSession: Session | null; 75 } 76 | { 77 readonly kind: "authenticated"; 78 readonly session: Session; 79 readonly savedAccounts: readonly SavedAccount[]; 80 } 81 | { 82 readonly kind: "error"; 83 readonly error: AuthError; 84 readonly savedAccounts: readonly SavedAccount[]; 85 }; 86 87function createUnauthenticated( 88 savedAccounts: readonly SavedAccount[], 89): AuthState { 90 return { kind: "unauthenticated", savedAccounts }; 91} 92 93function createLoading( 94 savedAccounts: readonly SavedAccount[], 95 previousSession: Session | null = null, 96): AuthState { 97 return { kind: "loading", savedAccounts, previousSession }; 98} 99 100function createAuthenticated( 101 session: Session, 102 savedAccounts: readonly SavedAccount[], 103): AuthState { 104 return { kind: "authenticated", session, savedAccounts }; 105} 106 107function createError( 108 error: AuthError, 109 savedAccounts: readonly SavedAccount[], 110): AuthState { 111 return { kind: "error", error, savedAccounts }; 112} 113 114const state = $state<{ current: AuthState }>({ 115 current: createLoading([]), 116}); 117 118function applyLocaleFromSession(sessionInfo: { 119 preferredLocale?: string | null; 120}): void { 121 if (sessionInfo.preferredLocale) { 122 setLocale(sessionInfo.preferredLocale as SupportedLocale); 123 } 124} 125 126function sessionToSavedAccount(session: Session): SavedAccount { 127 return { 128 did: unsafeAsDid(session.did), 129 handle: unsafeAsHandle(session.handle), 130 accessJwt: unsafeAsAccessToken(session.accessJwt), 131 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 132 }; 133} 134 135interface StoredSession { 136 readonly did: string; 137 readonly handle: string; 138 readonly accessJwt: string; 139 readonly refreshJwt: string; 140 readonly email?: string; 141 readonly emailConfirmed?: boolean; 142 readonly preferredChannel?: string; 143 readonly preferredChannelVerified?: boolean; 144 readonly preferredLocale?: string | null; 145} 146 147function parseStoredSession(json: string): Result<StoredSession, Error> { 148 try { 149 const parsed = JSON.parse(json); 150 if ( 151 typeof parsed === "object" && 152 parsed !== null && 153 typeof parsed.did === "string" && 154 typeof parsed.handle === "string" && 155 typeof parsed.accessJwt === "string" && 156 typeof parsed.refreshJwt === "string" 157 ) { 158 return ok(parsed as StoredSession); 159 } 160 return err(new Error("Invalid session format")); 161 } catch (e) { 162 return err(e instanceof Error ? e : new Error("Failed to parse session")); 163 } 164} 165 166function parseStoredAccounts(json: string): Result<SavedAccount[], Error> { 167 try { 168 const parsed = JSON.parse(json); 169 if (!Array.isArray(parsed)) { 170 return err(new Error("Invalid accounts format")); 171 } 172 const accounts: SavedAccount[] = parsed 173 .filter( 174 ( 175 a, 176 ): a is { 177 did: string; 178 handle: string; 179 accessJwt: string; 180 refreshJwt: string; 181 } => 182 typeof a === "object" && 183 a !== null && 184 typeof a.did === "string" && 185 typeof a.handle === "string" && 186 typeof a.accessJwt === "string" && 187 typeof a.refreshJwt === "string", 188 ) 189 .map((a) => ({ 190 did: unsafeAsDid(a.did), 191 handle: unsafeAsHandle(a.handle), 192 accessJwt: unsafeAsAccessToken(a.accessJwt), 193 refreshJwt: unsafeAsRefreshToken(a.refreshJwt), 194 })); 195 return ok(accounts); 196 } catch (e) { 197 return err(e instanceof Error ? e : new Error("Failed to parse accounts")); 198 } 199} 200 201function loadSessionFromStorage(): StoredSession | null { 202 const stored = localStorage.getItem(STORAGE_KEY); 203 if (!stored) return null; 204 const result = parseStoredSession(stored); 205 return isOk(result) ? result.value : null; 206} 207 208function loadSavedAccountsFromStorage(): readonly SavedAccount[] { 209 const stored = localStorage.getItem(ACCOUNTS_KEY); 210 if (!stored) return []; 211 const result = parseStoredAccounts(stored); 212 return isOk(result) ? result.value : []; 213} 214 215function persistSession(session: Session | null): void { 216 if (session) { 217 localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 218 } else { 219 localStorage.removeItem(STORAGE_KEY); 220 } 221} 222 223function persistSavedAccounts(accounts: readonly SavedAccount[]): void { 224 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 225} 226 227function updateSavedAccounts( 228 accounts: readonly SavedAccount[], 229 session: Session, 230): readonly SavedAccount[] { 231 const newAccount = sessionToSavedAccount(session); 232 const filtered = accounts.filter((a) => a.did !== newAccount.did); 233 return [...filtered, newAccount]; 234} 235 236function removeSavedAccountByDid( 237 accounts: readonly SavedAccount[], 238 did: Did, 239): readonly SavedAccount[] { 240 return accounts.filter((a) => a.did !== did); 241} 242 243function findSavedAccount( 244 accounts: readonly SavedAccount[], 245 did: Did, 246): SavedAccount | undefined { 247 return accounts.find((a) => a.did === did); 248} 249 250function getSavedAccounts(): readonly SavedAccount[] { 251 return state.current.savedAccounts; 252} 253 254function setState(newState: AuthState): void { 255 state.current = newState; 256} 257 258function setAuthenticated(session: Session): void { 259 const accounts = updateSavedAccounts(getSavedAccounts(), session); 260 persistSession(session); 261 persistSavedAccounts(accounts); 262 setState(createAuthenticated(session, accounts)); 263} 264 265function setUnauthenticated(): void { 266 persistSession(null); 267 setState(createUnauthenticated(getSavedAccounts())); 268} 269 270function setError(error: AuthError): void { 271 setState(createError(error, getSavedAccounts())); 272} 273 274function setLoading(previousSession: Session | null = null): void { 275 setState(createLoading(getSavedAccounts(), previousSession)); 276} 277 278export function clearError(): void { 279 if (state.current.kind === "error") { 280 setState(createUnauthenticated(getSavedAccounts())); 281 } 282} 283 284async function tryRefreshToken(): Promise<string | null> { 285 if (state.current.kind !== "authenticated") return null; 286 const currentSession = state.current.session; 287 try { 288 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 289 const sessionInfo = await api.getSession( 290 unsafeAsAccessToken(tokens.access_token), 291 ); 292 const session: Session = { 293 ...sessionInfo, 294 accessJwt: unsafeAsAccessToken(tokens.access_token), 295 refreshJwt: tokens.refresh_token 296 ? unsafeAsRefreshToken(tokens.refresh_token) 297 : currentSession.refreshJwt, 298 }; 299 setAuthenticated(session); 300 return session.accessJwt; 301 } catch { 302 return null; 303 } 304} 305 306import { setTokenRefreshCallback } from "./api.ts"; 307 308export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 309 setTokenRefreshCallback(tryRefreshToken); 310 const savedAccounts = loadSavedAccountsFromStorage(); 311 setState(createLoading(savedAccounts)); 312 313 const oauthCallback = checkForOAuthCallback(); 314 if (oauthCallback) { 315 clearOAuthCallbackParams(); 316 try { 317 const tokens = await handleOAuthCallback( 318 oauthCallback.code, 319 oauthCallback.state, 320 ); 321 const sessionInfo = await api.getSession( 322 unsafeAsAccessToken(tokens.access_token), 323 ); 324 const session: Session = { 325 ...sessionInfo, 326 accessJwt: unsafeAsAccessToken(tokens.access_token), 327 refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), 328 }; 329 setAuthenticated(session); 330 applyLocaleFromSession(session); 331 return { oauthLoginCompleted: true }; 332 } catch (e) { 333 clearAllOAuthState(); 334 setError({ 335 type: "oauth", 336 message: e instanceof Error ? e.message : "OAuth login failed", 337 }); 338 return { oauthLoginCompleted: false }; 339 } 340 } 341 342 const stored = loadSessionFromStorage(); 343 if (stored) { 344 try { 345 const sessionInfo = await api.getSession( 346 unsafeAsAccessToken(stored.accessJwt), 347 ); 348 const session: Session = { 349 ...sessionInfo, 350 accessJwt: unsafeAsAccessToken(stored.accessJwt), 351 refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), 352 }; 353 setAuthenticated(session); 354 applyLocaleFromSession(session); 355 } catch (e) { 356 if (e instanceof ApiError && e.status === 401) { 357 try { 358 const tokens = await refreshOAuthToken(stored.refreshJwt); 359 const sessionInfo = await api.getSession( 360 unsafeAsAccessToken(tokens.access_token), 361 ); 362 const session: Session = { 363 ...sessionInfo, 364 accessJwt: unsafeAsAccessToken(tokens.access_token), 365 refreshJwt: tokens.refresh_token 366 ? unsafeAsRefreshToken(tokens.refresh_token) 367 : unsafeAsRefreshToken(stored.refreshJwt), 368 }; 369 setAuthenticated(session); 370 applyLocaleFromSession(session); 371 } catch (refreshError) { 372 console.error("Token refresh failed during init:", refreshError); 373 setUnauthenticated(); 374 } 375 } else { 376 console.error("Non-401 error during getSession:", e); 377 setUnauthenticated(); 378 } 379 } 380 } else { 381 setState(createUnauthenticated(savedAccounts)); 382 } 383 384 return { oauthLoginCompleted: false }; 385} 386 387export async function login( 388 identifier: string, 389 password: string, 390): Promise<Result<Session, AuthError>> { 391 const currentState = state.current; 392 const previousSession = currentState.kind === "authenticated" 393 ? currentState.session 394 : null; 395 setLoading(previousSession); 396 397 const result = await typedApi.createSession(identifier, password); 398 if (isErr(result)) { 399 const error = toAuthError(result.error); 400 setError(error); 401 return err(error); 402 } 403 404 setAuthenticated(result.value); 405 return ok(result.value); 406} 407 408export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 409 clearAllOAuthState(); 410 setLoading(); 411 try { 412 await startOAuthLogin(); 413 return ok(undefined); 414 } catch (e) { 415 const error = toAuthError(e); 416 setError(error); 417 return err(error); 418 } 419} 420 421export async function register( 422 params: CreateAccountParams, 423): Promise<Result<CreateAccountResult, AuthError>> { 424 try { 425 const result = await api.createAccount(params); 426 return ok(result); 427 } catch (e) { 428 return err(toAuthError(e)); 429 } 430} 431 432export async function confirmSignup( 433 did: Did, 434 verificationCode: string, 435): Promise<Result<Session, AuthError>> { 436 setLoading(); 437 try { 438 const result = await api.confirmSignup(did, verificationCode); 439 setAuthenticated(result); 440 return ok(result); 441 } catch (e) { 442 const error = toAuthError(e); 443 setError(error); 444 return err(error); 445 } 446} 447 448export async function resendVerification( 449 did: Did, 450): Promise<Result<void, AuthError>> { 451 try { 452 await api.resendVerification(did); 453 return ok(undefined); 454 } catch (e) { 455 return err(toAuthError(e)); 456 } 457} 458 459export function setSession(session: { 460 did: string; 461 handle: string; 462 accessJwt: string; 463 refreshJwt: string; 464}): void { 465 const newSession: Session = { 466 did: unsafeAsDid(session.did), 467 handle: unsafeAsHandle(session.handle), 468 accessJwt: unsafeAsAccessToken(session.accessJwt), 469 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 470 }; 471 setAuthenticated(newSession); 472} 473 474export async function logout(): Promise<Result<void, AuthError>> { 475 if (state.current.kind === "authenticated") { 476 const { session } = state.current; 477 const did = unsafeAsDid(session.did); 478 try { 479 await fetch("/oauth/revoke", { 480 method: "POST", 481 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 482 body: new URLSearchParams({ token: session.refreshJwt }), 483 }); 484 } catch { 485 // Ignore revocation errors 486 } 487 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 488 persistSavedAccounts(accounts); 489 persistSession(null); 490 setState(createUnauthenticated(accounts)); 491 } else { 492 setUnauthenticated(); 493 } 494 return ok(undefined); 495} 496 497export async function switchAccount( 498 did: Did, 499): Promise<Result<Session, AuthError>> { 500 const account = findSavedAccount(getSavedAccounts(), did); 501 if (!account) { 502 return err({ type: "validation", message: "Account not found" }); 503 } 504 505 setLoading(); 506 507 try { 508 const sessionInfo = await api.getSession(account.accessJwt); 509 const session: Session = { 510 ...sessionInfo, 511 accessJwt: account.accessJwt, 512 refreshJwt: account.refreshJwt, 513 }; 514 setAuthenticated(session); 515 return ok(session); 516 } catch (e) { 517 if (e instanceof ApiError && e.status === 401) { 518 try { 519 const tokens = await refreshOAuthToken(account.refreshJwt); 520 const sessionInfo = await api.getSession( 521 unsafeAsAccessToken(tokens.access_token), 522 ); 523 const session: Session = { 524 ...sessionInfo, 525 accessJwt: unsafeAsAccessToken(tokens.access_token), 526 refreshJwt: tokens.refresh_token 527 ? unsafeAsRefreshToken(tokens.refresh_token) 528 : account.refreshJwt, 529 }; 530 setAuthenticated(session); 531 return ok(session); 532 } catch { 533 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 534 persistSavedAccounts(accounts); 535 const error: AuthError = { 536 type: "unauthorized", 537 message: "Session expired. Please log in again.", 538 }; 539 setState(createError(error, accounts)); 540 return err(error); 541 } 542 } 543 const error = toAuthError(e); 544 setError(error); 545 return err(error); 546 } 547} 548 549export function forgetAccount(did: Did): void { 550 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 551 persistSavedAccounts(accounts); 552 setState({ 553 ...state.current, 554 savedAccounts: accounts, 555 } as AuthState); 556} 557 558export function getAuthState(): AuthState { 559 return state.current; 560} 561 562export async function refreshSession(): Promise<Result<Session, AuthError>> { 563 if (state.current.kind !== "authenticated") { 564 return err({ type: "unauthorized", message: "Not authenticated" }); 565 } 566 const currentSession = state.current.session; 567 try { 568 const sessionInfo = await api.getSession(currentSession.accessJwt); 569 const session: Session = { 570 ...sessionInfo, 571 accessJwt: currentSession.accessJwt, 572 refreshJwt: currentSession.refreshJwt, 573 }; 574 setAuthenticated(session); 575 return ok(session); 576 } catch (e) { 577 console.error("Failed to refresh session:", e); 578 return err(toAuthError(e)); 579 } 580} 581 582export function getToken(): AccessToken | null { 583 if (state.current.kind === "authenticated") { 584 return state.current.session.accessJwt; 585 } 586 return null; 587} 588 589export async function getValidToken(): Promise<AccessToken | null> { 590 if (state.current.kind !== "authenticated") return null; 591 const currentSession = state.current.session; 592 try { 593 await api.getSession(currentSession.accessJwt); 594 return currentSession.accessJwt; 595 } catch (e) { 596 if (e instanceof ApiError && e.status === 401) { 597 try { 598 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 599 const sessionInfo = await api.getSession( 600 unsafeAsAccessToken(tokens.access_token), 601 ); 602 const session: Session = { 603 ...sessionInfo, 604 accessJwt: unsafeAsAccessToken(tokens.access_token), 605 refreshJwt: tokens.refresh_token 606 ? unsafeAsRefreshToken(tokens.refresh_token) 607 : currentSession.refreshJwt, 608 }; 609 setAuthenticated(session); 610 return session.accessJwt; 611 } catch { 612 return null; 613 } 614 } 615 return null; 616 } 617} 618 619export function isAuthenticated(): boolean { 620 return state.current.kind === "authenticated"; 621} 622 623export function isLoading(): boolean { 624 return state.current.kind === "loading"; 625} 626 627export function getError(): AuthError | null { 628 return state.current.kind === "error" ? state.current.error : null; 629} 630 631export function getSession(): Session | null { 632 return state.current.kind === "authenticated" ? state.current.session : null; 633} 634 635export function matchAuthState<T>(handlers: { 636 unauthenticated: (accounts: readonly SavedAccount[]) => T; 637 loading: ( 638 accounts: readonly SavedAccount[], 639 previousSession: Session | null, 640 ) => T; 641 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; 642 error: (error: AuthError, accounts: readonly SavedAccount[]) => T; 643}): T { 644 const current = state.current; 645 switch (current.kind) { 646 case "unauthenticated": 647 return handlers.unauthenticated(current.savedAccounts); 648 case "loading": 649 return handlers.loading(current.savedAccounts, current.previousSession); 650 case "authenticated": 651 return handlers.authenticated(current.session, current.savedAccounts); 652 case "error": 653 return handlers.error(current.error, current.savedAccounts); 654 default: 655 return assertNever(current); 656 } 657} 658 659export function _testSetState(newState: { 660 session: Session | null; 661 loading: boolean; 662 error: string | null; 663 savedAccounts?: SavedAccount[]; 664}): void { 665 const accounts = newState.savedAccounts ?? []; 666 if (newState.loading) { 667 setState(createLoading(accounts, newState.session)); 668 } else if (newState.error) { 669 setState( 670 createError({ type: "unknown", message: newState.error }, accounts), 671 ); 672 } else if (newState.session) { 673 setState(createAuthenticated(newState.session, accounts)); 674 } else { 675 setState(createUnauthenticated(accounts)); 676 } 677} 678 679export function _testResetState(): void { 680 setState(createLoading([])); 681} 682 683export function _testReset(): void { 684 _testResetState(); 685 localStorage.removeItem(STORAGE_KEY); 686 localStorage.removeItem(ACCOUNTS_KEY); 687} 688 689export { type Session };