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