Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
20 kB · 690 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691import { api, ApiError, castSession } from "./api.ts";import type { CreateAccountParams, CreateAccountResult, 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 { 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<StoredSession, Error> { 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<SavedAccount[], Error> { 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<AccessToken | null> { 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<Result<Session, AuthError>> { const currentState = state.current; const previousSession = currentState.kind === "authenticated" ? currentState.session : null; setLoading(previousSession);
try { const session = await api.createSession(identifier, password); setAuthenticated(session); return ok(session); } catch (e) { const error = toAuthError(e); setError(error); return err(error); }}
export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 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<Result<CreateAccountResult, AuthError>> { 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<Result<Session, AuthError>> { setLoading(); try { const result = await api.confirmSignup(did, verificationCode); const session = castSession(result); setAuthenticated(session); return ok(session); } catch (e) { const error = toAuthError(e); setError(error); return err(error); }}
export async function resendVerification( did: Did,): Promise<Result<void, AuthError>> { 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), contactKind: "none", accountKind: "active", isAdmin: false, }; setAuthenticated(newSession);}
export async function logout(): Promise<Result<void, AuthError>> { 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<Result<Session, AuthError>> { 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<Result<Session, AuthError>> { 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<AccessToken | null> { 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<T>(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: throw new Error(`Unexpected auth state: ${(current as { kind: string }).kind}`); }}
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 };