diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index ae91de5f..5f5c869a 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -19,10 +19,19 @@ import { import { getContext } from 'svelte'; import { SvelteURL, SvelteURLSearchParams } from 'svelte/reactivity'; import oauthMetadata from '../../static/oauth-client-metadata.json'; +import { + type AuthAccount, + clearActive, + dropAccount, + loadAccounts, + persistActive, + readActiveDid, + reconcileAccounts, + saveAccounts, + upsertAccount +} from './auth/accounts'; export const AUTH_KEY = Symbol('auth'); -const CURRENT_DID_KEY = 'tangled.currentDid'; -const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; @@ -53,6 +62,8 @@ export interface CurrentUser { avatar?: string; } +export type { AuthAccount } from './auth/accounts'; + export interface Auth { readonly agent: OAuthUserAgent | null; readonly currentDid: Did | null; @@ -61,10 +72,15 @@ export interface Auth { readonly profileLoading: boolean; readonly authenticating: boolean; readonly currentUser: CurrentUser | null; + readonly accounts: AuthAccount[]; refresh(): Promise; signIn(identifier: string, returnTo?: string): Promise; + addAccount(identifier: string, returnTo?: string): Promise; completeSignIn(): Promise; + switchAccount(did: Did): Promise; + removeAccount(did: Did): Promise; signOut(): Promise; + signOutAll(): Promise; } type MiniDoc = { @@ -103,30 +119,6 @@ const configure = () => { configured = true; }; -const readStoredDid = (): Did | null => { - if (!browser) return null; - const value = localStorage.getItem(CURRENT_DID_KEY); - return value?.startsWith('did:') ? (value as Did) : null; -}; - -const persistAuth = (did: string, handle: string) => { - if (!browser) return; - localStorage.setItem(CURRENT_DID_KEY, did); - localStorage.setItem(CURRENT_HANDLE_KEY, handle); - const secure = location.protocol === 'https:' ? '; secure' : ''; - const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; - document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; - document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; -}; - -const clearAuth = () => { - if (!browser) return; - localStorage.removeItem(CURRENT_DID_KEY); - localStorage.removeItem(CURRENT_HANDLE_KEY); - document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; - document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; -}; - const errorMessage = (cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); return message.toLowerCase().includes('unknown state') @@ -148,7 +140,7 @@ const resolveProfile = async (identifier: string): Promise = }; } } catch { - // fall through to local resolution + // try local resolver next. } try { @@ -162,6 +154,14 @@ const resolveProfile = async (identifier: string): Promise = } }; +const returnToFromState = (state: object | null): string => { + if (state && typeof state === 'object' && 'returnTo' in state) { + const returnTo = state.returnTo; + if (typeof returnTo === 'string') return returnTo; + } + return '/'; +}; + export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { const seed = initial ?? null; let agent = $state(null); @@ -171,47 +171,77 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au ); let error = $state(null); let authenticating = $state(false); + let accounts = $state([]); + + // merge atcute's stored sessions with persisted account metadata. + const syncAccounts = () => { + accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); + saveAccounts(accounts); + }; + + const resetLoggedOut = () => { + clearActive(); + agent = null; + currentDid = null; + profile = null; + syncAccounts(); + }; const hydrateProfile = async (did: Did) => { const resolved = await resolveProfile(did); profile = resolved ?? { did, handle: did }; - persistAuth(did, profile.handle); + const meta = upsertAccount(loadAccounts(), { + did, + handle: profile.handle, + avatar: profile.avatar, + addedAt: Math.floor(Date.now() / 1000) + }); + saveAccounts(meta); + accounts = reconcileAccounts(listStoredSessions(), meta); + persistActive(did, profile.handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); agent = nextAgent; + error = null; const did = nextAgent.sub as Did; currentDid = did; - if (browser) localStorage.setItem(CURRENT_DID_KEY, did); + const known = loadAccounts().find((account) => account.did === did); + persistActive(did, known?.handle ?? did); void hydrateProfile(did); }; + // prune dead sessions when re-adoption fails. + const activate = async (did: Did): Promise => { + try { + const session = await getSession(did, { allowStale: true }); + adoptSession(session); + return true; + } catch (cause) { + deleteStoredSession(did); + saveAccounts(dropAccount(loadAccounts(), did)); + error = errorMessage(cause); + return false; + } + }; + const refresh = async () => { if (!browser) return; configure(); error = null; + syncAccounts(); - const preferred = readStoredDid() ?? currentDid ?? listStoredSessions()[0] ?? null; - if (!preferred) { - agent = null; - currentDid = null; - profile = null; - clearAuth(); - return; + const candidates: Did[] = []; + for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { + if (candidate && !candidates.includes(candidate)) candidates.push(candidate); } - try { - const session = await getSession(preferred, { allowStale: true }); - adoptSession(session); - } catch (cause) { - deleteStoredSession(preferred); - clearAuth(); - agent = null; - currentDid = null; - profile = null; - error = errorMessage(cause); + for (const candidate of candidates) { + if (await activate(candidate)) return; } + + resetLoggedOut(); }; const signIn = async (identifier: string, returnTo = '/') => { @@ -264,9 +294,7 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au const { session, state } = await finalizeAuthorization(params); adoptSession(session); - return typeof (state as { returnTo?: unknown } | null)?.returnTo === 'string' - ? (state as { returnTo: string }).returnTo - : '/'; + return returnToFromState(state); } catch (cause) { error = errorMessage(cause); throw cause; @@ -275,25 +303,62 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au } }; - const signOut = async () => { + const switchAccount = async (did: Did) => { + if (!browser) return; + configure(); error = null; - const did = currentDid; + if (!(await activate(did))) syncAccounts(); + }; + + const removeAccount = async (did: Did) => { + if (!browser) return; + error = null; + const wasActive = currentDid === did; try { - if (agent) { + if (wasActive && agent) { await agent.signOut(); - } else if (did) { + } else { deleteStoredSession(did); } } catch { - if (did) deleteStoredSession(did); - } finally { - clearAuth(); - agent = null; - currentDid = null; - profile = null; + deleteStoredSession(did); + } + + saveAccounts(dropAccount(loadAccounts(), did)); + accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); + + if (wasActive) { + const next = accounts[0]?.did ?? null; + if (next) { + await activate(next); + } else { + resetLoggedOut(); + } } }; + const signOut = async () => { + if (currentDid) { + await removeAccount(currentDid); + } else { + resetLoggedOut(); + } + }; + + const signOutAll = async () => { + error = null; + try { + if (agent) await agent.signOut(); + } catch { + // remove local session state below. + } + if (browser) { + for (const did of listStoredSessions()) deleteStoredSession(did); + } + saveAccounts([]); + resetLoggedOut(); + }; + return { get agent() { return agent; @@ -321,10 +386,17 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au avatar: profile?.avatar }; }, + get accounts() { + return accounts; + }, refresh, signIn, + addAccount: signIn, completeSignIn, - signOut + switchAccount, + removeAccount, + signOut, + signOutAll }; }; diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts new file mode 100644 index 00000000..6cdcbd74 --- /dev/null +++ b/web/src/lib/auth/accounts.ts @@ -0,0 +1,109 @@ +import { browser } from '$app/environment'; +import type { Did } from '@atcute/lexicons/syntax'; + +// atcute owns oauth sessions; this stores metadata/order and active-account cookies. + +export const CURRENT_DID_KEY = 'tangled.currentDid'; +export const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; +const ACCOUNTS_KEY = 'tangled.accounts'; + +// appview account cap parity +export const MAX_ACCOUNTS = 20; + +export interface AuthAccount { + did: Did; + handle: string; + avatar?: string; + // unix seconds; appview parity + addedAt: number; +} + +const isDid = (value: unknown): value is Did => + typeof value === 'string' && value.startsWith('did:'); + +const isAccount = (value: unknown): value is AuthAccount => + !!value && + typeof value === 'object' && + isDid((value as AuthAccount).did) && + typeof (value as AuthAccount).handle === 'string'; + +export const loadAccounts = (): AuthAccount[] => { + if (!browser) return []; + try { + const raw = localStorage.getItem(ACCOUNTS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isAccount).map((account) => ({ + did: account.did, + handle: account.handle, + avatar: account.avatar, + addedAt: typeof account.addedAt === 'number' ? account.addedAt : 0 + })); + } catch { + return []; + } +}; + +export const saveAccounts = (accounts: readonly AuthAccount[]): void => { + if (!browser) return; + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); +}; + +// stored sessions are authoritative; metadata supplies order, handle, and avatar. +export const reconcileAccounts = ( + stored: readonly Did[], + meta: readonly AuthAccount[] +): AuthAccount[] => { + const storedSet = new Set(stored); + const known = new Set(meta.map((account) => account.did)); + const ordered = meta.filter((account) => storedSet.has(account.did)); + for (const did of stored) { + if (!known.has(did)) { + ordered.push({ did, handle: did, addedAt: Math.floor(Date.now() / 1000) }); + } + } + return ordered; +}; + +// dedupe by did, preserve insertion order, and keep the original addedAt. +export const upsertAccount = ( + accounts: readonly AuthAccount[], + account: AuthAccount +): AuthAccount[] => { + const index = accounts.findIndex((existing) => existing.did === account.did); + if (index >= 0) { + const next = accounts.slice(); + next[index] = { ...account, addedAt: accounts[index].addedAt }; + return next; + } + if (accounts.length >= MAX_ACCOUNTS) return accounts.slice(); + return [...accounts, account]; +}; + +export const dropAccount = (accounts: readonly AuthAccount[], did: Did): AuthAccount[] => + accounts.filter((account) => account.did !== did); + +export const persistActive = (did: string, handle: string): void => { + if (!browser) return; + localStorage.setItem(CURRENT_DID_KEY, did); + localStorage.setItem(CURRENT_HANDLE_KEY, handle); + const secure = location.protocol === 'https:' ? '; secure' : ''; + const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; + document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; + document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; +}; + +export const clearActive = (): void => { + if (!browser) return; + localStorage.removeItem(CURRENT_DID_KEY); + localStorage.removeItem(CURRENT_HANDLE_KEY); + document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; + document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; +}; + +export const readActiveDid = (): Did | null => { + if (!browser) return null; + const value = localStorage.getItem(CURRENT_DID_KEY); + return isDid(value) ? value : null; +}; diff --git a/web/src/lib/auth/agent.ts b/web/src/lib/auth/agent.ts new file mode 100644 index 00000000..a7065fbf --- /dev/null +++ b/web/src/lib/auth/agent.ts @@ -0,0 +1,32 @@ +import { Client, ok } from '@atcute/client'; +import { mainSchema as getServiceAuthSchema } from '@atcute/atproto/types/server/getServiceAuth'; +import type { Nsid } from '@atcute/lexicons/syntax'; +import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; + +export const createClient = (agent: OAuthUserAgent): Client => new Client({ handler: agent }); + +export interface ServiceAuthOptions { + // service did for the jwt aud claim + aud: string; + lxm: string; + // clamped to at least 60s, matching appview's service client. + expiresInSeconds?: number; +} + +// did:web service id, with ports percent-encoded like serviceauth didweb. +export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, '%3A')}`; + +// mint a service-auth jwt for knot/spindle xrpc calls. +export const mintServiceAuth = async ( + agent: OAuthUserAgent, + { aud, lxm, expiresInSeconds = 60 }: ServiceAuthOptions +): Promise => { + const client = createClient(agent); + const exp = Math.floor(Date.now() / 1000) + Math.max(expiresInSeconds, 60); + const { token } = await ok( + client.call(getServiceAuthSchema, { + params: { aud, exp, lxm: lxm as Nsid } + }) + ); + return token; +}; diff --git a/web/src/lib/auth/guards.ts b/web/src/lib/auth/guards.ts new file mode 100644 index 00000000..95cda3fd --- /dev/null +++ b/web/src/lib/auth/guards.ts @@ -0,0 +1,29 @@ +import { goto } from '$app/navigation'; +import { resolve } from '$app/paths'; +import { redirect, type RequestEvent } from '@sveltejs/kit'; +import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from './accounts'; +import type { Auth } from '../auth.svelte'; + +export interface RequireAuthResult { + did: string; + handle: string; +} + +const loginWithReturn = (returnUrl: string): string => + `/login?return_url=${encodeURIComponent(returnUrl)}`; + +export const requireAuth = (event: RequestEvent): RequireAuthResult => { + const did = event.cookies.get(CURRENT_DID_KEY); + if (!did) { + redirect(302, loginWithReturn(event.url.pathname + event.url.search)); + } + return { did, handle: event.cookies.get(CURRENT_HANDLE_KEY) ?? did }; +}; + +export const requireAuthClient = (auth: Auth, url: URL): boolean => { + if (auth.currentDid) return true; + void goto(resolve(loginWithReturn(url.pathname + url.search) as '/login'), { + replaceState: true + }); + return false; +};