import { api, ApiError, type CreateAccountParams, type CreateAccountResult, typedApi, } from "./api.ts"; import type { Session } from "./types/api.ts"; import { type AccessToken, type Did, type Handle, type RefreshToken, unsafeAsAccessToken, unsafeAsDid, unsafeAsHandle, unsafeAsRefreshToken, } from "./types/branded.ts"; import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; import { assertNever } from "./types/exhaustive.ts"; import { checkForOAuthCallback, clearAllOAuthState, clearOAuthCallbackParams, handleOAuthCallback, refreshOAuthToken, startOAuthLogin, } from "./oauth.ts"; import { setLocale, type SupportedLocale } from "./i18n.ts"; const STORAGE_KEY = "tranquil_pds_session"; const ACCOUNTS_KEY = "tranquil_pds_accounts"; export interface SavedAccount { readonly did: Did; readonly handle: Handle; readonly accessJwt: AccessToken; readonly refreshJwt: RefreshToken; } export type AuthError = | { readonly type: "network"; readonly message: string } | { readonly type: "unauthorized"; readonly message: string } | { readonly type: "validation"; readonly message: string } | { readonly type: "oauth"; readonly message: string } | { readonly type: "unknown"; readonly message: string }; function toAuthError(e: unknown): AuthError { if (e instanceof ApiError) { if (e.status === 401) { return { type: "unauthorized", message: e.message }; } return { type: "validation", message: e.message }; } if (e instanceof Error) { if (e.message.includes("network") || e.message.includes("fetch")) { return { type: "network", message: e.message }; } return { type: "unknown", message: e.message }; } return { type: "unknown", message: "An unknown error occurred" }; } type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error"; export type AuthState = | { readonly kind: "unauthenticated"; readonly savedAccounts: readonly SavedAccount[]; } | { readonly kind: "loading"; readonly savedAccounts: readonly SavedAccount[]; readonly previousSession: Session | null; } | { readonly kind: "authenticated"; readonly session: Session; readonly savedAccounts: readonly SavedAccount[]; } | { readonly kind: "error"; readonly error: AuthError; readonly savedAccounts: readonly SavedAccount[]; }; function createUnauthenticated( savedAccounts: readonly SavedAccount[], ): AuthState { return { kind: "unauthenticated", savedAccounts }; } function createLoading( savedAccounts: readonly SavedAccount[], previousSession: Session | null = null, ): AuthState { return { kind: "loading", savedAccounts, previousSession }; } function createAuthenticated( session: Session, savedAccounts: readonly SavedAccount[], ): AuthState { return { kind: "authenticated", session, savedAccounts }; } function createError( error: AuthError, savedAccounts: readonly SavedAccount[], ): AuthState { return { kind: "error", error, savedAccounts }; } const state = $state<{ current: AuthState }>({ current: createLoading([]), }); function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null; }): void { if (sessionInfo.preferredLocale) { setLocale(sessionInfo.preferredLocale as SupportedLocale); } } function sessionToSavedAccount(session: Session): SavedAccount { return { did: unsafeAsDid(session.did), handle: unsafeAsHandle(session.handle), accessJwt: unsafeAsAccessToken(session.accessJwt), refreshJwt: unsafeAsRefreshToken(session.refreshJwt), }; } interface StoredSession { readonly did: string; readonly handle: string; readonly accessJwt: string; readonly refreshJwt: string; readonly email?: string; readonly emailConfirmed?: boolean; readonly preferredChannel?: string; readonly preferredChannelVerified?: boolean; readonly preferredLocale?: string | null; } function parseStoredSession(json: string): Result { try { const parsed = JSON.parse(json); if ( typeof parsed === "object" && parsed !== null && typeof parsed.did === "string" && typeof parsed.handle === "string" && typeof parsed.accessJwt === "string" && typeof parsed.refreshJwt === "string" ) { return ok(parsed as StoredSession); } return err(new Error("Invalid session format")); } catch (e) { return err(e instanceof Error ? e : new Error("Failed to parse session")); } } function parseStoredAccounts(json: string): Result { try { const parsed = JSON.parse(json); if (!Array.isArray(parsed)) { return err(new Error("Invalid accounts format")); } const accounts: SavedAccount[] = parsed .filter( ( a, ): a is { did: string; handle: string; accessJwt: string; refreshJwt: string; } => typeof a === "object" && a !== null && typeof a.did === "string" && typeof a.handle === "string" && typeof a.accessJwt === "string" && typeof a.refreshJwt === "string", ) .map((a) => ({ did: unsafeAsDid(a.did), handle: unsafeAsHandle(a.handle), accessJwt: unsafeAsAccessToken(a.accessJwt), refreshJwt: unsafeAsRefreshToken(a.refreshJwt), })); return ok(accounts); } catch (e) { return err(e instanceof Error ? e : new Error("Failed to parse accounts")); } } function loadSessionFromStorage(): StoredSession | null { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return null; const result = parseStoredSession(stored); return isOk(result) ? result.value : null; } function loadSavedAccountsFromStorage(): readonly SavedAccount[] { const stored = localStorage.getItem(ACCOUNTS_KEY); if (!stored) return []; const result = parseStoredAccounts(stored); return isOk(result) ? result.value : []; } function persistSession(session: Session | null): void { if (session) { localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); } else { localStorage.removeItem(STORAGE_KEY); } } function persistSavedAccounts(accounts: readonly SavedAccount[]): void { localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); } function updateSavedAccounts( accounts: readonly SavedAccount[], session: Session, ): readonly SavedAccount[] { const newAccount = sessionToSavedAccount(session); const filtered = accounts.filter((a) => a.did !== newAccount.did); return [...filtered, newAccount]; } function removeSavedAccountByDid( accounts: readonly SavedAccount[], did: Did, ): readonly SavedAccount[] { return accounts.filter((a) => a.did !== did); } function findSavedAccount( accounts: readonly SavedAccount[], did: Did, ): SavedAccount | undefined { return accounts.find((a) => a.did === did); } function getSavedAccounts(): readonly SavedAccount[] { return state.current.savedAccounts; } function setState(newState: AuthState): void { state.current = newState; } function setAuthenticated(session: Session): void { const accounts = updateSavedAccounts(getSavedAccounts(), session); persistSession(session); persistSavedAccounts(accounts); setState(createAuthenticated(session, accounts)); } function setUnauthenticated(): void { persistSession(null); setState(createUnauthenticated(getSavedAccounts())); } function setError(error: AuthError): void { setState(createError(error, getSavedAccounts())); } function setLoading(previousSession: Session | null = null): void { setState(createLoading(getSavedAccounts(), previousSession)); } export function clearError(): void { if (state.current.kind === "error") { setState(createUnauthenticated(getSavedAccounts())); } } async function tryRefreshToken(): Promise { if (state.current.kind !== "authenticated") return null; const currentSession = state.current.session; try { const tokens = await refreshOAuthToken(currentSession.refreshJwt); const sessionInfo = await api.getSession( unsafeAsAccessToken(tokens.access_token), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(tokens.access_token), refreshJwt: tokens.refresh_token ? unsafeAsRefreshToken(tokens.refresh_token) : currentSession.refreshJwt, }; setAuthenticated(session); return session.accessJwt; } catch { return null; } } import { setTokenRefreshCallback } from "./api.ts"; export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { setTokenRefreshCallback(tryRefreshToken); const savedAccounts = loadSavedAccountsFromStorage(); setState(createLoading(savedAccounts)); const oauthCallback = checkForOAuthCallback(); if (oauthCallback) { clearOAuthCallbackParams(); try { const tokens = await handleOAuthCallback( oauthCallback.code, oauthCallback.state, ); const sessionInfo = await api.getSession( unsafeAsAccessToken(tokens.access_token), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(tokens.access_token), refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), }; setAuthenticated(session); applyLocaleFromSession(session); return { oauthLoginCompleted: true }; } catch (e) { clearAllOAuthState(); setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed", }); return { oauthLoginCompleted: false }; } } const stored = loadSessionFromStorage(); if (stored) { try { const sessionInfo = await api.getSession( unsafeAsAccessToken(stored.accessJwt), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(stored.accessJwt), refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), }; setAuthenticated(session); applyLocaleFromSession(session); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { const tokens = await refreshOAuthToken(stored.refreshJwt); const sessionInfo = await api.getSession( unsafeAsAccessToken(tokens.access_token), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(tokens.access_token), refreshJwt: tokens.refresh_token ? unsafeAsRefreshToken(tokens.refresh_token) : unsafeAsRefreshToken(stored.refreshJwt), }; setAuthenticated(session); applyLocaleFromSession(session); } catch (refreshError) { console.error("Token refresh failed during init:", refreshError); setUnauthenticated(); } } else { console.error("Non-401 error during getSession:", e); setUnauthenticated(); } } } else { setState(createUnauthenticated(savedAccounts)); } return { oauthLoginCompleted: false }; } export async function login( identifier: string, password: string, ): Promise> { const currentState = state.current; const previousSession = currentState.kind === "authenticated" ? currentState.session : null; setLoading(previousSession); const result = await typedApi.createSession(identifier, password); if (isErr(result)) { const error = toAuthError(result.error); setError(error); return err(error); } setAuthenticated(result.value); return ok(result.value); } export async function loginWithOAuth(): Promise> { clearAllOAuthState(); setLoading(); try { await startOAuthLogin(); return ok(undefined); } catch (e) { const error = toAuthError(e); setError(error); return err(error); } } export async function register( params: CreateAccountParams, ): Promise> { try { const result = await api.createAccount(params); return ok(result); } catch (e) { return err(toAuthError(e)); } } export async function confirmSignup( did: Did, verificationCode: string, ): Promise> { setLoading(); try { const result = await api.confirmSignup(did, verificationCode); setAuthenticated(result); return ok(result); } catch (e) { const error = toAuthError(e); setError(error); return err(error); } } export async function resendVerification( did: Did, ): Promise> { try { await api.resendVerification(did); return ok(undefined); } catch (e) { return err(toAuthError(e)); } } export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string; }): void { const newSession: Session = { did: unsafeAsDid(session.did), handle: unsafeAsHandle(session.handle), accessJwt: unsafeAsAccessToken(session.accessJwt), refreshJwt: unsafeAsRefreshToken(session.refreshJwt), }; setAuthenticated(newSession); } export async function logout(): Promise> { if (state.current.kind === "authenticated") { const { session } = state.current; const did = unsafeAsDid(session.did); try { await fetch("/oauth/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: session.refreshJwt }), }); } catch { // Ignore revocation errors } const accounts = removeSavedAccountByDid(getSavedAccounts(), did); persistSavedAccounts(accounts); persistSession(null); setState(createUnauthenticated(accounts)); } else { setUnauthenticated(); } return ok(undefined); } export async function switchAccount( did: Did, ): Promise> { const account = findSavedAccount(getSavedAccounts(), did); if (!account) { return err({ type: "validation", message: "Account not found" }); } setLoading(); try { const sessionInfo = await api.getSession(account.accessJwt); const session: Session = { ...sessionInfo, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt, }; setAuthenticated(session); return ok(session); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { const tokens = await refreshOAuthToken(account.refreshJwt); const sessionInfo = await api.getSession( unsafeAsAccessToken(tokens.access_token), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(tokens.access_token), refreshJwt: tokens.refresh_token ? unsafeAsRefreshToken(tokens.refresh_token) : account.refreshJwt, }; setAuthenticated(session); return ok(session); } catch { const accounts = removeSavedAccountByDid(getSavedAccounts(), did); persistSavedAccounts(accounts); const error: AuthError = { type: "unauthorized", message: "Session expired. Please log in again.", }; setState(createError(error, accounts)); return err(error); } } const error = toAuthError(e); setError(error); return err(error); } } export function forgetAccount(did: Did): void { const accounts = removeSavedAccountByDid(getSavedAccounts(), did); persistSavedAccounts(accounts); setState({ ...state.current, savedAccounts: accounts, } as AuthState); } export function getAuthState(): AuthState { return state.current; } export async function refreshSession(): Promise> { if (state.current.kind !== "authenticated") { return err({ type: "unauthorized", message: "Not authenticated" }); } const currentSession = state.current.session; try { const sessionInfo = await api.getSession(currentSession.accessJwt); const session: Session = { ...sessionInfo, accessJwt: currentSession.accessJwt, refreshJwt: currentSession.refreshJwt, }; setAuthenticated(session); return ok(session); } catch (e) { console.error("Failed to refresh session:", e); return err(toAuthError(e)); } } export function getToken(): AccessToken | null { if (state.current.kind === "authenticated") { return state.current.session.accessJwt; } return null; } export async function getValidToken(): Promise { if (state.current.kind !== "authenticated") return null; const currentSession = state.current.session; try { await api.getSession(currentSession.accessJwt); return currentSession.accessJwt; } catch (e) { if (e instanceof ApiError && e.status === 401) { try { const tokens = await refreshOAuthToken(currentSession.refreshJwt); const sessionInfo = await api.getSession( unsafeAsAccessToken(tokens.access_token), ); const session: Session = { ...sessionInfo, accessJwt: unsafeAsAccessToken(tokens.access_token), refreshJwt: tokens.refresh_token ? unsafeAsRefreshToken(tokens.refresh_token) : currentSession.refreshJwt, }; setAuthenticated(session); return session.accessJwt; } catch { return null; } } return null; } } export function isAuthenticated(): boolean { return state.current.kind === "authenticated"; } export function isLoading(): boolean { return state.current.kind === "loading"; } export function getError(): AuthError | null { return state.current.kind === "error" ? state.current.error : null; } export function getSession(): Session | null { return state.current.kind === "authenticated" ? state.current.session : null; } export function matchAuthState(handlers: { unauthenticated: (accounts: readonly SavedAccount[]) => T; loading: ( accounts: readonly SavedAccount[], previousSession: Session | null, ) => T; authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; error: (error: AuthError, accounts: readonly SavedAccount[]) => T; }): T { const current = state.current; switch (current.kind) { case "unauthenticated": return handlers.unauthenticated(current.savedAccounts); case "loading": return handlers.loading(current.savedAccounts, current.previousSession); case "authenticated": return handlers.authenticated(current.session, current.savedAccounts); case "error": return handlers.error(current.error, current.savedAccounts); default: return assertNever(current); } } export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[]; }): void { const accounts = newState.savedAccounts ?? []; if (newState.loading) { setState(createLoading(accounts, newState.session)); } else if (newState.error) { setState( createError({ type: "unknown", message: newState.error }, accounts), ); } else if (newState.session) { setState(createAuthenticated(newState.session, accounts)); } else { setState(createUnauthenticated(accounts)); } } export function _testResetState(): void { setState(createLoading([])); } export function _testReset(): void { _testResetState(); localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(ACCOUNTS_KEY); } export { type Session };