Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

web/auth: support multiple accounts

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jul 13, 2026, 3:58 PM +0300) 7a577424 1625ce79

+302 -60
+132 -60
web/src/lib/auth.svelte.ts
··· 19 19 import { getContext } from 'svelte'; 20 20 import { SvelteURL, SvelteURLSearchParams } from 'svelte/reactivity'; 21 21 import oauthMetadata from '../../static/oauth-client-metadata.json'; 22 + import { 23 + type AuthAccount, 24 + clearActive, 25 + dropAccount, 26 + loadAccounts, 27 + persistActive, 28 + readActiveDid, 29 + reconcileAccounts, 30 + saveAccounts, 31 + upsertAccount 32 + } from './auth/accounts'; 22 33 23 34 export const AUTH_KEY = Symbol('auth'); 24 - const CURRENT_DID_KEY = 'tangled.currentDid'; 25 - const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; 26 35 const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; 27 36 const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; 28 37 const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; ··· 53 62 avatar?: string; 54 63 } 55 64 65 + export type { AuthAccount } from './auth/accounts'; 66 + 56 67 export interface Auth { 57 68 readonly agent: OAuthUserAgent | null; 58 69 readonly currentDid: Did | null; ··· 61 72 readonly profileLoading: boolean; 62 73 readonly authenticating: boolean; 63 74 readonly currentUser: CurrentUser | null; 75 + readonly accounts: AuthAccount[]; 64 76 refresh(): Promise<void>; 65 77 signIn(identifier: string, returnTo?: string): Promise<void>; 78 + addAccount(identifier: string, returnTo?: string): Promise<void>; 66 79 completeSignIn(): Promise<string>; 80 + switchAccount(did: Did): Promise<void>; 81 + removeAccount(did: Did): Promise<void>; 67 82 signOut(): Promise<void>; 83 + signOutAll(): Promise<void>; 68 84 } 69 85 70 86 type MiniDoc = { ··· 103 119 configured = true; 104 120 }; 105 121 106 - const readStoredDid = (): Did | null => { 107 - if (!browser) return null; 108 - const value = localStorage.getItem(CURRENT_DID_KEY); 109 - return value?.startsWith('did:') ? (value as Did) : null; 110 - }; 111 - 112 - const persistAuth = (did: string, handle: string) => { 113 - if (!browser) return; 114 - localStorage.setItem(CURRENT_DID_KEY, did); 115 - localStorage.setItem(CURRENT_HANDLE_KEY, handle); 116 - const secure = location.protocol === 'https:' ? '; secure' : ''; 117 - const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; 118 - document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; 119 - document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; 120 - }; 121 - 122 - const clearAuth = () => { 123 - if (!browser) return; 124 - localStorage.removeItem(CURRENT_DID_KEY); 125 - localStorage.removeItem(CURRENT_HANDLE_KEY); 126 - document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; 127 - document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; 128 - }; 129 - 130 122 const errorMessage = (cause: unknown) => { 131 123 const message = cause instanceof Error ? cause.message : String(cause); 132 124 return message.toLowerCase().includes('unknown state') ··· 148 140 }; 149 141 } 150 142 } catch { 151 - // fall through to local resolution 143 + // try local resolver next. 152 144 } 153 145 154 146 try { ··· 162 154 } 163 155 }; 164 156 157 + const returnToFromState = (state: object | null): string => { 158 + if (state && typeof state === 'object' && 'returnTo' in state) { 159 + const returnTo = state.returnTo; 160 + if (typeof returnTo === 'string') return returnTo; 161 + } 162 + return '/'; 163 + }; 164 + 165 165 export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { 166 166 const seed = initial ?? null; 167 167 let agent = $state<OAuthUserAgent | null>(null); ··· 171 171 ); 172 172 let error = $state<string | null>(null); 173 173 let authenticating = $state(false); 174 + let accounts = $state<AuthAccount[]>([]); 175 + 176 + // merge atcute's stored sessions with persisted account metadata. 177 + const syncAccounts = () => { 178 + accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); 179 + saveAccounts(accounts); 180 + }; 181 + 182 + const resetLoggedOut = () => { 183 + clearActive(); 184 + agent = null; 185 + currentDid = null; 186 + profile = null; 187 + syncAccounts(); 188 + }; 174 189 175 190 const hydrateProfile = async (did: Did) => { 176 191 const resolved = await resolveProfile(did); 177 192 profile = resolved ?? { did, handle: did }; 178 - persistAuth(did, profile.handle); 193 + const meta = upsertAccount(loadAccounts(), { 194 + did, 195 + handle: profile.handle, 196 + avatar: profile.avatar, 197 + addedAt: Math.floor(Date.now() / 1000) 198 + }); 199 + saveAccounts(meta); 200 + accounts = reconcileAccounts(listStoredSessions(), meta); 201 + persistActive(did, profile.handle); 179 202 }; 180 203 181 204 const adoptSession = (session: OAuthSession) => { 182 205 const nextAgent = new OAuthUserAgent(session); 183 206 agent = nextAgent; 207 + error = null; 184 208 const did = nextAgent.sub as Did; 185 209 currentDid = did; 186 - if (browser) localStorage.setItem(CURRENT_DID_KEY, did); 210 + const known = loadAccounts().find((account) => account.did === did); 211 + persistActive(did, known?.handle ?? did); 187 212 void hydrateProfile(did); 188 213 }; 189 214 215 + // prune dead sessions when re-adoption fails. 216 + const activate = async (did: Did): Promise<boolean> => { 217 + try { 218 + const session = await getSession(did, { allowStale: true }); 219 + adoptSession(session); 220 + return true; 221 + } catch (cause) { 222 + deleteStoredSession(did); 223 + saveAccounts(dropAccount(loadAccounts(), did)); 224 + error = errorMessage(cause); 225 + return false; 226 + } 227 + }; 228 + 190 229 const refresh = async () => { 191 230 if (!browser) return; 192 231 configure(); 193 232 error = null; 233 + syncAccounts(); 194 234 195 - const preferred = readStoredDid() ?? currentDid ?? listStoredSessions()[0] ?? null; 196 - if (!preferred) { 197 - agent = null; 198 - currentDid = null; 199 - profile = null; 200 - clearAuth(); 201 - return; 235 + const candidates: Did[] = []; 236 + for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { 237 + if (candidate && !candidates.includes(candidate)) candidates.push(candidate); 202 238 } 203 239 204 - try { 205 - const session = await getSession(preferred, { allowStale: true }); 206 - adoptSession(session); 207 - } catch (cause) { 208 - deleteStoredSession(preferred); 209 - clearAuth(); 210 - agent = null; 211 - currentDid = null; 212 - profile = null; 213 - error = errorMessage(cause); 240 + for (const candidate of candidates) { 241 + if (await activate(candidate)) return; 214 242 } 243 + 244 + resetLoggedOut(); 215 245 }; 216 246 217 247 const signIn = async (identifier: string, returnTo = '/') => { ··· 264 294 const { session, state } = await finalizeAuthorization(params); 265 295 adoptSession(session); 266 296 267 - return typeof (state as { returnTo?: unknown } | null)?.returnTo === 'string' 268 - ? (state as { returnTo: string }).returnTo 269 - : '/'; 297 + return returnToFromState(state); 270 298 } catch (cause) { 271 299 error = errorMessage(cause); 272 300 throw cause; ··· 275 303 } 276 304 }; 277 305 278 - const signOut = async () => { 306 + const switchAccount = async (did: Did) => { 307 + if (!browser) return; 308 + configure(); 279 309 error = null; 280 - const did = currentDid; 310 + if (!(await activate(did))) syncAccounts(); 311 + }; 312 + 313 + const removeAccount = async (did: Did) => { 314 + if (!browser) return; 315 + error = null; 316 + const wasActive = currentDid === did; 281 317 try { 282 - if (agent) { 318 + if (wasActive && agent) { 283 319 await agent.signOut(); 284 - } else if (did) { 320 + } else { 285 321 deleteStoredSession(did); 286 322 } 287 323 } catch { 288 - if (did) deleteStoredSession(did); 289 - } finally { 290 - clearAuth(); 291 - agent = null; 292 - currentDid = null; 293 - profile = null; 324 + deleteStoredSession(did); 325 + } 326 + 327 + saveAccounts(dropAccount(loadAccounts(), did)); 328 + accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); 329 + 330 + if (wasActive) { 331 + const next = accounts[0]?.did ?? null; 332 + if (next) { 333 + await activate(next); 334 + } else { 335 + resetLoggedOut(); 336 + } 294 337 } 295 338 }; 296 339 340 + const signOut = async () => { 341 + if (currentDid) { 342 + await removeAccount(currentDid); 343 + } else { 344 + resetLoggedOut(); 345 + } 346 + }; 347 + 348 + const signOutAll = async () => { 349 + error = null; 350 + try { 351 + if (agent) await agent.signOut(); 352 + } catch { 353 + // remove local session state below. 354 + } 355 + if (browser) { 356 + for (const did of listStoredSessions()) deleteStoredSession(did); 357 + } 358 + saveAccounts([]); 359 + resetLoggedOut(); 360 + }; 361 + 297 362 return { 298 363 get agent() { 299 364 return agent; ··· 321 386 avatar: profile?.avatar 322 387 }; 323 388 }, 389 + get accounts() { 390 + return accounts; 391 + }, 324 392 refresh, 325 393 signIn, 394 + addAccount: signIn, 326 395 completeSignIn, 327 - signOut 396 + switchAccount, 397 + removeAccount, 398 + signOut, 399 + signOutAll 328 400 }; 329 401 }; 330 402
+109
web/src/lib/auth/accounts.ts
··· 1 + import { browser } from '$app/environment'; 2 + import type { Did } from '@atcute/lexicons/syntax'; 3 + 4 + // atcute owns oauth sessions; this stores metadata/order and active-account cookies. 5 + 6 + export const CURRENT_DID_KEY = 'tangled.currentDid'; 7 + export const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; 8 + const ACCOUNTS_KEY = 'tangled.accounts'; 9 + 10 + // appview account cap parity 11 + export const MAX_ACCOUNTS = 20; 12 + 13 + export interface AuthAccount { 14 + did: Did; 15 + handle: string; 16 + avatar?: string; 17 + // unix seconds; appview parity 18 + addedAt: number; 19 + } 20 + 21 + const isDid = (value: unknown): value is Did => 22 + typeof value === 'string' && value.startsWith('did:'); 23 + 24 + const isAccount = (value: unknown): value is AuthAccount => 25 + !!value && 26 + typeof value === 'object' && 27 + isDid((value as AuthAccount).did) && 28 + typeof (value as AuthAccount).handle === 'string'; 29 + 30 + export const loadAccounts = (): AuthAccount[] => { 31 + if (!browser) return []; 32 + try { 33 + const raw = localStorage.getItem(ACCOUNTS_KEY); 34 + if (!raw) return []; 35 + const parsed = JSON.parse(raw) as unknown; 36 + if (!Array.isArray(parsed)) return []; 37 + return parsed.filter(isAccount).map((account) => ({ 38 + did: account.did, 39 + handle: account.handle, 40 + avatar: account.avatar, 41 + addedAt: typeof account.addedAt === 'number' ? account.addedAt : 0 42 + })); 43 + } catch { 44 + return []; 45 + } 46 + }; 47 + 48 + export const saveAccounts = (accounts: readonly AuthAccount[]): void => { 49 + if (!browser) return; 50 + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 51 + }; 52 + 53 + // stored sessions are authoritative; metadata supplies order, handle, and avatar. 54 + export const reconcileAccounts = ( 55 + stored: readonly Did[], 56 + meta: readonly AuthAccount[] 57 + ): AuthAccount[] => { 58 + const storedSet = new Set(stored); 59 + const known = new Set(meta.map((account) => account.did)); 60 + const ordered = meta.filter((account) => storedSet.has(account.did)); 61 + for (const did of stored) { 62 + if (!known.has(did)) { 63 + ordered.push({ did, handle: did, addedAt: Math.floor(Date.now() / 1000) }); 64 + } 65 + } 66 + return ordered; 67 + }; 68 + 69 + // dedupe by did, preserve insertion order, and keep the original addedAt. 70 + export const upsertAccount = ( 71 + accounts: readonly AuthAccount[], 72 + account: AuthAccount 73 + ): AuthAccount[] => { 74 + const index = accounts.findIndex((existing) => existing.did === account.did); 75 + if (index >= 0) { 76 + const next = accounts.slice(); 77 + next[index] = { ...account, addedAt: accounts[index].addedAt }; 78 + return next; 79 + } 80 + if (accounts.length >= MAX_ACCOUNTS) return accounts.slice(); 81 + return [...accounts, account]; 82 + }; 83 + 84 + export const dropAccount = (accounts: readonly AuthAccount[], did: Did): AuthAccount[] => 85 + accounts.filter((account) => account.did !== did); 86 + 87 + export const persistActive = (did: string, handle: string): void => { 88 + if (!browser) return; 89 + localStorage.setItem(CURRENT_DID_KEY, did); 90 + localStorage.setItem(CURRENT_HANDLE_KEY, handle); 91 + const secure = location.protocol === 'https:' ? '; secure' : ''; 92 + const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; 93 + document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; 94 + document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; 95 + }; 96 + 97 + export const clearActive = (): void => { 98 + if (!browser) return; 99 + localStorage.removeItem(CURRENT_DID_KEY); 100 + localStorage.removeItem(CURRENT_HANDLE_KEY); 101 + document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; 102 + document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; 103 + }; 104 + 105 + export const readActiveDid = (): Did | null => { 106 + if (!browser) return null; 107 + const value = localStorage.getItem(CURRENT_DID_KEY); 108 + return isDid(value) ? value : null; 109 + };
+32
web/src/lib/auth/agent.ts
··· 1 + import { Client, ok } from '@atcute/client'; 2 + import { mainSchema as getServiceAuthSchema } from '@atcute/atproto/types/server/getServiceAuth'; 3 + import type { Nsid } from '@atcute/lexicons/syntax'; 4 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 5 + 6 + export const createClient = (agent: OAuthUserAgent): Client => new Client({ handler: agent }); 7 + 8 + export interface ServiceAuthOptions { 9 + // service did for the jwt aud claim 10 + aud: string; 11 + lxm: string; 12 + // clamped to at least 60s, matching appview's service client. 13 + expiresInSeconds?: number; 14 + } 15 + 16 + // did:web service id, with ports percent-encoded like serviceauth didweb. 17 + export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, '%3A')}`; 18 + 19 + // mint a service-auth jwt for knot/spindle xrpc calls. 20 + export const mintServiceAuth = async ( 21 + agent: OAuthUserAgent, 22 + { aud, lxm, expiresInSeconds = 60 }: ServiceAuthOptions 23 + ): Promise<string> => { 24 + const client = createClient(agent); 25 + const exp = Math.floor(Date.now() / 1000) + Math.max(expiresInSeconds, 60); 26 + const { token } = await ok( 27 + client.call(getServiceAuthSchema, { 28 + params: { aud, exp, lxm: lxm as Nsid } 29 + }) 30 + ); 31 + return token; 32 + };
+29
web/src/lib/auth/guards.ts
··· 1 + import { goto } from '$app/navigation'; 2 + import { resolve } from '$app/paths'; 3 + import { redirect, type RequestEvent } from '@sveltejs/kit'; 4 + import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from './accounts'; 5 + import type { Auth } from '../auth.svelte'; 6 + 7 + export interface RequireAuthResult { 8 + did: string; 9 + handle: string; 10 + } 11 + 12 + const loginWithReturn = (returnUrl: string): string => 13 + `/login?return_url=${encodeURIComponent(returnUrl)}`; 14 + 15 + export const requireAuth = (event: RequestEvent): RequireAuthResult => { 16 + const did = event.cookies.get(CURRENT_DID_KEY); 17 + if (!did) { 18 + redirect(302, loginWithReturn(event.url.pathname + event.url.search)); 19 + } 20 + return { did, handle: event.cookies.get(CURRENT_HANDLE_KEY) ?? did }; 21 + }; 22 + 23 + export const requireAuthClient = (auth: Auth, url: URL): boolean => { 24 + if (auth.currentDid) return true; 25 + void goto(resolve(loginWithReturn(url.pathname + url.search) as '/login'), { 26 + replaceState: true 27 + }); 28 + return false; 29 + };