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 clearOAuthCallbackParams, 24 handleOAuthCallback, 25 refreshOAuthToken, 26 startOAuthLogin, 27} from "./oauth.ts"; 28import { setLocale, type SupportedLocale } from "./i18n.ts"; 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 ( 174 a, 175 ): a is { 176 did: string; 177 handle: string; 178 accessJwt: string; 179 refreshJwt: string; 180 } => 181 typeof a === "object" && 182 a !== null && 183 typeof a.did === "string" && 184 typeof a.handle === "string" && 185 typeof a.accessJwt === "string" && 186 typeof a.refreshJwt === "string", 187 ) 188 .map((a) => ({ 189 did: unsafeAsDid(a.did), 190 handle: unsafeAsHandle(a.handle), 191 accessJwt: unsafeAsAccessToken(a.accessJwt), 192 refreshJwt: unsafeAsRefreshToken(a.refreshJwt), 193 })); 194 return ok(accounts); 195 } catch (e) { 196 return err(e instanceof Error ? e : new Error("Failed to parse accounts")); 197 } 198} 199 200function loadSessionFromStorage(): StoredSession | null { 201 const stored = localStorage.getItem(STORAGE_KEY); 202 if (!stored) return null; 203 const result = parseStoredSession(stored); 204 return isOk(result) ? result.value : null; 205} 206 207function loadSavedAccountsFromStorage(): readonly SavedAccount[] { 208 const stored = localStorage.getItem(ACCOUNTS_KEY); 209 if (!stored) return []; 210 const result = parseStoredAccounts(stored); 211 return isOk(result) ? result.value : []; 212} 213 214function persistSession(session: Session | null): void { 215 if (session) { 216 localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 217 } else { 218 localStorage.removeItem(STORAGE_KEY); 219 } 220} 221 222function persistSavedAccounts(accounts: readonly SavedAccount[]): void { 223 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 224} 225 226function updateSavedAccounts( 227 accounts: readonly SavedAccount[], 228 session: Session, 229): readonly SavedAccount[] { 230 const newAccount = sessionToSavedAccount(session); 231 const filtered = accounts.filter((a) => a.did !== newAccount.did); 232 return [...filtered, newAccount]; 233} 234 235function removeSavedAccountByDid( 236 accounts: readonly SavedAccount[], 237 did: Did, 238): readonly SavedAccount[] { 239 return accounts.filter((a) => a.did !== did); 240} 241 242function findSavedAccount( 243 accounts: readonly SavedAccount[], 244 did: Did, 245): SavedAccount | undefined { 246 return accounts.find((a) => a.did === did); 247} 248 249function getSavedAccounts(): readonly SavedAccount[] { 250 return state.current.savedAccounts; 251} 252 253function setState(newState: AuthState): void { 254 state.current = newState; 255} 256 257function setAuthenticated(session: Session): void { 258 const accounts = updateSavedAccounts(getSavedAccounts(), session); 259 persistSession(session); 260 persistSavedAccounts(accounts); 261 setState(createAuthenticated(session, accounts)); 262} 263 264function setUnauthenticated(): void { 265 persistSession(null); 266 setState(createUnauthenticated(getSavedAccounts())); 267} 268 269function setError(error: AuthError): void { 270 setState(createError(error, getSavedAccounts())); 271} 272 273function setLoading(previousSession: Session | null = null): void { 274 setState(createLoading(getSavedAccounts(), previousSession)); 275} 276 277async function tryRefreshToken(): Promise<string | null> { 278 if (state.current.kind !== "authenticated") return null; 279 const currentSession = state.current.session; 280 try { 281 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 282 const sessionInfo = await api.getSession( 283 unsafeAsAccessToken(tokens.access_token), 284 ); 285 const session: Session = { 286 ...sessionInfo, 287 accessJwt: unsafeAsAccessToken(tokens.access_token), 288 refreshJwt: tokens.refresh_token 289 ? unsafeAsRefreshToken(tokens.refresh_token) 290 : currentSession.refreshJwt, 291 }; 292 setAuthenticated(session); 293 return session.accessJwt; 294 } catch { 295 return null; 296 } 297} 298 299import { setTokenRefreshCallback } from "./api.ts"; 300 301export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 302 setTokenRefreshCallback(tryRefreshToken); 303 const savedAccounts = loadSavedAccountsFromStorage(); 304 setState(createLoading(savedAccounts)); 305 306 const oauthCallback = checkForOAuthCallback(); 307 if (oauthCallback) { 308 clearOAuthCallbackParams(); 309 try { 310 const tokens = await handleOAuthCallback( 311 oauthCallback.code, 312 oauthCallback.state, 313 ); 314 const sessionInfo = await api.getSession( 315 unsafeAsAccessToken(tokens.access_token), 316 ); 317 const session: Session = { 318 ...sessionInfo, 319 accessJwt: unsafeAsAccessToken(tokens.access_token), 320 refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), 321 }; 322 setAuthenticated(session); 323 applyLocaleFromSession(session); 324 return { oauthLoginCompleted: true }; 325 } catch (e) { 326 setError({ 327 type: "oauth", 328 message: e instanceof Error ? e.message : "OAuth login failed", 329 }); 330 return { oauthLoginCompleted: false }; 331 } 332 } 333 334 const stored = loadSessionFromStorage(); 335 if (stored) { 336 try { 337 const sessionInfo = await api.getSession( 338 unsafeAsAccessToken(stored.accessJwt), 339 ); 340 const session: Session = { 341 ...sessionInfo, 342 accessJwt: unsafeAsAccessToken(stored.accessJwt), 343 refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), 344 }; 345 setAuthenticated(session); 346 applyLocaleFromSession(session); 347 } catch (e) { 348 if (e instanceof ApiError && e.status === 401) { 349 try { 350 const tokens = await refreshOAuthToken(stored.refreshJwt); 351 const sessionInfo = await api.getSession( 352 unsafeAsAccessToken(tokens.access_token), 353 ); 354 const session: Session = { 355 ...sessionInfo, 356 accessJwt: unsafeAsAccessToken(tokens.access_token), 357 refreshJwt: tokens.refresh_token 358 ? unsafeAsRefreshToken(tokens.refresh_token) 359 : unsafeAsRefreshToken(stored.refreshJwt), 360 }; 361 setAuthenticated(session); 362 applyLocaleFromSession(session); 363 } catch (refreshError) { 364 console.error("Token refresh failed during init:", refreshError); 365 setUnauthenticated(); 366 } 367 } else { 368 console.error("Non-401 error during getSession:", e); 369 setUnauthenticated(); 370 } 371 } 372 } else { 373 setState(createUnauthenticated(savedAccounts)); 374 } 375 376 return { oauthLoginCompleted: false }; 377} 378 379export async function login( 380 identifier: string, 381 password: string, 382): Promise<Result<Session, AuthError>> { 383 const currentState = state.current; 384 const previousSession = currentState.kind === "authenticated" 385 ? currentState.session 386 : null; 387 setLoading(previousSession); 388 389 const result = await typedApi.createSession(identifier, password); 390 if (isErr(result)) { 391 const error = toAuthError(result.error); 392 setError(error); 393 return err(error); 394 } 395 396 setAuthenticated(result.value); 397 return ok(result.value); 398} 399 400export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 401 setLoading(); 402 try { 403 await startOAuthLogin(); 404 return ok(undefined); 405 } catch (e) { 406 const error = toAuthError(e); 407 setError(error); 408 return err(error); 409 } 410} 411 412export async function register( 413 params: CreateAccountParams, 414): Promise<Result<CreateAccountResult, AuthError>> { 415 try { 416 const result = await api.createAccount(params); 417 return ok(result); 418 } catch (e) { 419 return err(toAuthError(e)); 420 } 421} 422 423export async function confirmSignup( 424 did: Did, 425 verificationCode: string, 426): Promise<Result<Session, AuthError>> { 427 setLoading(); 428 try { 429 const result = await api.confirmSignup(did, verificationCode); 430 setAuthenticated(result); 431 return ok(result); 432 } catch (e) { 433 const error = toAuthError(e); 434 setError(error); 435 return err(error); 436 } 437} 438 439export async function resendVerification( 440 did: Did, 441): Promise<Result<void, AuthError>> { 442 try { 443 await api.resendVerification(did); 444 return ok(undefined); 445 } catch (e) { 446 return err(toAuthError(e)); 447 } 448} 449 450export function setSession(session: { 451 did: string; 452 handle: string; 453 accessJwt: string; 454 refreshJwt: string; 455}): void { 456 const newSession: Session = { 457 did: unsafeAsDid(session.did), 458 handle: unsafeAsHandle(session.handle), 459 accessJwt: unsafeAsAccessToken(session.accessJwt), 460 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 461 }; 462 setAuthenticated(newSession); 463} 464 465export async function logout(): Promise<Result<void, AuthError>> { 466 if (state.current.kind === "authenticated") { 467 const { session } = state.current; 468 const did = unsafeAsDid(session.did); 469 try { 470 await fetch("/oauth/revoke", { 471 method: "POST", 472 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 473 body: new URLSearchParams({ token: session.refreshJwt }), 474 }); 475 } catch { 476 // Ignore revocation errors 477 } 478 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 479 persistSavedAccounts(accounts); 480 persistSession(null); 481 setState(createUnauthenticated(accounts)); 482 } else { 483 setUnauthenticated(); 484 } 485 return ok(undefined); 486} 487 488export async function switchAccount( 489 did: Did, 490): Promise<Result<Session, AuthError>> { 491 const account = findSavedAccount(getSavedAccounts(), did); 492 if (!account) { 493 return err({ type: "validation", message: "Account not found" }); 494 } 495 496 setLoading(); 497 498 try { 499 const sessionInfo = await api.getSession(account.accessJwt); 500 const session: Session = { 501 ...sessionInfo, 502 accessJwt: account.accessJwt, 503 refreshJwt: account.refreshJwt, 504 }; 505 setAuthenticated(session); 506 return ok(session); 507 } catch (e) { 508 if (e instanceof ApiError && e.status === 401) { 509 try { 510 const tokens = await refreshOAuthToken(account.refreshJwt); 511 const sessionInfo = await api.getSession( 512 unsafeAsAccessToken(tokens.access_token), 513 ); 514 const session: Session = { 515 ...sessionInfo, 516 accessJwt: unsafeAsAccessToken(tokens.access_token), 517 refreshJwt: tokens.refresh_token 518 ? unsafeAsRefreshToken(tokens.refresh_token) 519 : account.refreshJwt, 520 }; 521 setAuthenticated(session); 522 return ok(session); 523 } catch { 524 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 525 persistSavedAccounts(accounts); 526 const error: AuthError = { 527 type: "unauthorized", 528 message: "Session expired. Please log in again.", 529 }; 530 setState(createError(error, accounts)); 531 return err(error); 532 } 533 } 534 const error = toAuthError(e); 535 setError(error); 536 return err(error); 537 } 538} 539 540export function forgetAccount(did: Did): void { 541 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 542 persistSavedAccounts(accounts); 543 setState({ 544 ...state.current, 545 savedAccounts: accounts, 546 } as AuthState); 547} 548 549export function getAuthState(): AuthState { 550 return state.current; 551} 552 553export async function refreshSession(): Promise<Result<Session, AuthError>> { 554 if (state.current.kind !== "authenticated") { 555 return err({ type: "unauthorized", message: "Not authenticated" }); 556 } 557 const currentSession = state.current.session; 558 try { 559 const sessionInfo = await api.getSession(currentSession.accessJwt); 560 const session: Session = { 561 ...sessionInfo, 562 accessJwt: currentSession.accessJwt, 563 refreshJwt: currentSession.refreshJwt, 564 }; 565 setAuthenticated(session); 566 return ok(session); 567 } catch (e) { 568 console.error("Failed to refresh session:", e); 569 return err(toAuthError(e)); 570 } 571} 572 573export function getToken(): AccessToken | null { 574 if (state.current.kind === "authenticated") { 575 return state.current.session.accessJwt; 576 } 577 return null; 578} 579 580export async function getValidToken(): Promise<AccessToken | null> { 581 if (state.current.kind !== "authenticated") return null; 582 const currentSession = state.current.session; 583 try { 584 await api.getSession(currentSession.accessJwt); 585 return currentSession.accessJwt; 586 } catch (e) { 587 if (e instanceof ApiError && e.status === 401) { 588 try { 589 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 590 const sessionInfo = await api.getSession( 591 unsafeAsAccessToken(tokens.access_token), 592 ); 593 const session: Session = { 594 ...sessionInfo, 595 accessJwt: unsafeAsAccessToken(tokens.access_token), 596 refreshJwt: tokens.refresh_token 597 ? unsafeAsRefreshToken(tokens.refresh_token) 598 : currentSession.refreshJwt, 599 }; 600 setAuthenticated(session); 601 return session.accessJwt; 602 } catch { 603 return null; 604 } 605 } 606 return null; 607 } 608} 609 610export function isAuthenticated(): boolean { 611 return state.current.kind === "authenticated"; 612} 613 614export function isLoading(): boolean { 615 return state.current.kind === "loading"; 616} 617 618export function getError(): AuthError | null { 619 return state.current.kind === "error" ? state.current.error : null; 620} 621 622export function getSession(): Session | null { 623 return state.current.kind === "authenticated" ? state.current.session : null; 624} 625 626export function matchAuthState<T>(handlers: { 627 unauthenticated: (accounts: readonly SavedAccount[]) => T; 628 loading: ( 629 accounts: readonly SavedAccount[], 630 previousSession: Session | null, 631 ) => T; 632 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; 633 error: (error: AuthError, accounts: readonly SavedAccount[]) => T; 634}): T { 635 const current = state.current; 636 switch (current.kind) { 637 case "unauthenticated": 638 return handlers.unauthenticated(current.savedAccounts); 639 case "loading": 640 return handlers.loading(current.savedAccounts, current.previousSession); 641 case "authenticated": 642 return handlers.authenticated(current.session, current.savedAccounts); 643 case "error": 644 return handlers.error(current.error, current.savedAccounts); 645 default: 646 return assertNever(current); 647 } 648} 649 650export function _testSetState(newState: { 651 session: Session | null; 652 loading: boolean; 653 error: string | null; 654 savedAccounts?: SavedAccount[]; 655}): void { 656 const accounts = newState.savedAccounts ?? []; 657 if (newState.loading) { 658 setState(createLoading(accounts, newState.session)); 659 } else if (newState.error) { 660 setState( 661 createError({ type: "unknown", message: newState.error }, accounts), 662 ); 663 } else if (newState.session) { 664 setState(createAuthenticated(newState.session, accounts)); 665 } else { 666 setState(createUnauthenticated(accounts)); 667 } 668} 669 670export function _testResetState(): void { 671 setState(createLoading([])); 672} 673 674export function _testReset(): void { 675 _testResetState(); 676 localStorage.removeItem(STORAGE_KEY); 677 localStorage.removeItem(ACCOUNTS_KEY); 678} 679 680export { type Session };