Monorepo for Tangled tangled.org
3

Configure Feed

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

core / web / src / lib / auth.svelte.ts
10 kB 417 lines
1import { browser } from "$app/environment"; 2import { 3 CompositeDidDocumentResolver, 4 LocalActorResolver, 5 PlcDidDocumentResolver, 6 WebDidDocumentResolver, 7 XrpcHandleResolver 8} from "@atcute/identity-resolver"; 9import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; 10import { 11 OAuthUserAgent, 12 configureOAuth, 13 createAuthorizationUrl, 14 deleteStoredSession, 15 finalizeAuthorization, 16 getSession, 17 listStoredSessions 18} from "@atcute/oauth-browser-client"; 19import { getContext } from "svelte"; 20import { SvelteURL, SvelteURLSearchParams } from "svelte/reactivity"; 21import oauthMetadata from "../../static/oauth-client-metadata.json"; 22import { 23 type AuthAccount, 24 clearActive, 25 dropAccount, 26 loadAccounts, 27 persistActive, 28 readActiveDid, 29 reconcileAccounts, 30 saveAccounts, 31 upsertAccount 32} from "./auth/accounts"; 33 34export const AUTH_KEY = Symbol("auth"); 35const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback"; 36const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; 37 38// local dev (localinfra) points these at the local pds/plc; defaults are the public network. 39const HANDLE_RESOLVER_URL = 40 (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ?? 41 "https://public.api.bsky.app"; 42const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( 43 /\/+$/, 44 "" 45); 46 47const identityResolver = new LocalActorResolver({ 48 handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }), 49 didDocumentResolver: new CompositeDidDocumentResolver({ 50 methods: { 51 plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}), 52 web: new WebDidDocumentResolver() 53 } 54 }) 55}); 56 57let configured = false; 58 59export interface AuthProfile { 60 did: Did; 61 handle: string; 62 avatar?: string; 63} 64 65export interface CurrentUser { 66 did: Did; 67 handle: string; 68 avatar?: string; 69} 70 71export type { AuthAccount } from "./auth/accounts"; 72 73export interface Auth { 74 readonly agent: OAuthUserAgent | null; 75 readonly currentDid: Did | null; 76 readonly profile: AuthProfile | null; 77 readonly error: string | null; 78 readonly profileLoading: boolean; 79 readonly authenticating: boolean; 80 readonly currentUser: CurrentUser | null; 81 readonly accounts: AuthAccount[]; 82 bobbinUrl: string; 83 refresh(): Promise<void>; 84 signIn(identifier: string, returnTo?: string): Promise<void>; 85 addAccount(identifier: string, returnTo?: string): Promise<void>; 86 completeSignIn(): Promise<string>; 87 switchAccount(did: Did): Promise<void>; 88 removeAccount(did: Did): Promise<void>; 89 signOut(): Promise<void>; 90 signOutAll(): Promise<void>; 91} 92 93type MiniDoc = { 94 did: Did; 95 handle: string; 96 pds?: string; 97 avatar?: string; 98}; 99 100type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; 101 102const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; 103const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false; 104const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT 105 ? DEV_CLIENT_ID 106 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); 107const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT 108 ? DEV_REDIRECT_URI 109 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); 110const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; 111 112const configure = () => { 113 if (!browser || configured) return; 114 115 configureOAuth({ 116 metadata: { 117 client_id: OAUTH_CLIENT_ID, 118 redirect_uri: OAUTH_REDIRECT_URI 119 }, 120 identityResolver 121 }); 122 123 configured = true; 124}; 125 126const errorMessage = (cause: unknown) => { 127 const message = cause instanceof Error ? cause.message : String(cause); 128 return message.toLowerCase().includes("unknown state") 129 ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost." 130 : message; 131}; 132 133const resolveProfile = async ( 134 identifier: string, 135 bobbinUrl: string 136): Promise<AuthProfile | null> => { 137 try { 138 const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl); 139 url.searchParams.set("identifier", identifier); 140 const response = await fetch(url, { headers: { accept: "application/json" } }); 141 if (response.ok) { 142 const profile = (await response.json()) as MiniDoc; 143 return { 144 did: profile.did, 145 handle: profile.handle, 146 avatar: profile.avatar 147 }; 148 } 149 } catch { 150 // try local resolver next. 151 } 152 153 try { 154 const identity = await identityResolver.resolve(identifier as ActorIdentifier); 155 return { 156 did: identity.did as Did, 157 handle: identity.handle 158 }; 159 } catch { 160 return null; 161 } 162}; 163 164const returnToFromState = (state: object | null): string => { 165 if (state && typeof state === "object" && "returnTo" in state) { 166 const returnTo = state.returnTo; 167 if (typeof returnTo === "string") return returnTo; 168 } 169 return "/"; 170}; 171 172export const createAuth = ( 173 bobbinUrl: string, 174 initial?: { did: string; handle: string } | null 175): Auth => { 176 const seed = initial ?? null; 177 let agent = $state<OAuthUserAgent | null>(null); 178 const bobbinUrlValue = bobbinUrl; 179 let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null); 180 let profile = $state<AuthProfile | null>( 181 seed ? { did: seed.did as Did, handle: seed.handle } : null 182 ); 183 let error = $state<string | null>(null); 184 let authenticating = $state(false); 185 let accounts = $state<AuthAccount[]>([]); 186 187 // merge atcute's stored sessions with persisted account metadata. 188 const syncAccounts = () => { 189 accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); 190 saveAccounts(accounts); 191 }; 192 193 const resetLoggedOut = () => { 194 clearActive(); 195 agent = null; 196 currentDid = null; 197 profile = null; 198 syncAccounts(); 199 }; 200 201 const hydrateProfile = async (did: Did) => { 202 const resolved = await resolveProfile(did, bobbinUrl); 203 profile = resolved ?? { did, handle: did }; 204 const meta = upsertAccount(loadAccounts(), { 205 did, 206 handle: profile.handle, 207 avatar: profile.avatar, 208 addedAt: Math.floor(Date.now() / 1000) 209 }); 210 saveAccounts(meta); 211 accounts = reconcileAccounts(listStoredSessions(), meta); 212 persistActive(did, profile.handle); 213 }; 214 215 const adoptSession = (session: OAuthSession) => { 216 const nextAgent = new OAuthUserAgent(session); 217 agent = nextAgent; 218 error = null; 219 const did = nextAgent.sub as Did; 220 currentDid = did; 221 const known = loadAccounts().find((account) => account.did === did); 222 persistActive(did, known?.handle ?? did); 223 void hydrateProfile(did); 224 }; 225 226 // prune dead sessions when re-adoption fails. 227 const activate = async (did: Did): Promise<boolean> => { 228 try { 229 const session = await getSession(did, { allowStale: true }); 230 adoptSession(session); 231 return true; 232 } catch (cause) { 233 deleteStoredSession(did); 234 saveAccounts(dropAccount(loadAccounts(), did)); 235 error = errorMessage(cause); 236 return false; 237 } 238 }; 239 240 const refresh = async () => { 241 if (!browser) return; 242 configure(); 243 error = null; 244 syncAccounts(); 245 246 const candidates: Did[] = []; 247 for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { 248 if (candidate && !candidates.includes(candidate)) candidates.push(candidate); 249 } 250 251 for (const candidate of candidates) { 252 if (await activate(candidate)) return; 253 } 254 255 resetLoggedOut(); 256 }; 257 258 const signIn = async (identifier: string, returnTo = "/") => { 259 if (!browser) return; 260 configure(); 261 error = null; 262 263 const trimmed = identifier.trim(); 264 if (!trimmed) { 265 error = "Handle or DID required"; 266 return; 267 } 268 269 if (location.hostname === "localhost") { 270 const url = new SvelteURL(location.href); 271 url.hostname = "127.0.0.1"; 272 url.searchParams.set("identifier", trimmed); 273 url.searchParams.set("return_url", returnTo); 274 location.replace(url); 275 return; 276 } 277 278 try { 279 const url = await createAuthorizationUrl({ 280 target: { 281 type: "account", 282 identifier: trimmed as ActorIdentifier 283 }, 284 scope: OAUTH_SCOPE, 285 state: { returnTo } 286 }); 287 288 window.location.assign(url.toString()); 289 } catch (cause) { 290 error = errorMessage(cause); 291 throw cause; 292 } 293 }; 294 295 const completeSignIn = async () => { 296 if (!browser) return "/"; 297 configure(); 298 error = null; 299 authenticating = true; 300 301 try { 302 const params = new SvelteURLSearchParams(location.hash.slice(1)); 303 history.replaceState(null, "", location.pathname + location.search); 304 305 const { session, state } = await finalizeAuthorization(params); 306 adoptSession(session); 307 308 return returnToFromState(state); 309 } catch (cause) { 310 error = errorMessage(cause); 311 throw cause; 312 } finally { 313 authenticating = false; 314 } 315 }; 316 317 const switchAccount = async (did: Did) => { 318 if (!browser) return; 319 configure(); 320 error = null; 321 if (!(await activate(did))) syncAccounts(); 322 }; 323 324 const removeAccount = async (did: Did) => { 325 if (!browser) return; 326 error = null; 327 const wasActive = currentDid === did; 328 try { 329 if (wasActive && agent) { 330 await agent.signOut(); 331 } else { 332 deleteStoredSession(did); 333 } 334 } catch { 335 deleteStoredSession(did); 336 } 337 338 saveAccounts(dropAccount(loadAccounts(), did)); 339 accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); 340 341 if (wasActive) { 342 const next = accounts[0]?.did ?? null; 343 if (next) { 344 await activate(next); 345 } else { 346 resetLoggedOut(); 347 } 348 } 349 }; 350 351 const signOut = async () => { 352 if (currentDid) { 353 await removeAccount(currentDid); 354 } else { 355 resetLoggedOut(); 356 } 357 }; 358 359 const signOutAll = async () => { 360 error = null; 361 try { 362 if (agent) await agent.signOut(); 363 } catch { 364 // remove local session state below. 365 } 366 if (browser) { 367 for (const did of listStoredSessions()) deleteStoredSession(did); 368 } 369 saveAccounts([]); 370 resetLoggedOut(); 371 }; 372 373 return { 374 get agent() { 375 return agent; 376 }, 377 get currentDid() { 378 return currentDid; 379 }, 380 get profile() { 381 return profile; 382 }, 383 get bobbinUrl() { 384 return bobbinUrlValue; 385 }, 386 get error() { 387 return error; 388 }, 389 get profileLoading() { 390 return currentDid !== null && profile === null; 391 }, 392 get authenticating() { 393 return authenticating; 394 }, 395 get currentUser() { 396 if (!currentDid) return null; 397 return { 398 did: currentDid, 399 handle: profile?.handle ?? currentDid, 400 avatar: profile?.avatar 401 }; 402 }, 403 get accounts() { 404 return accounts; 405 }, 406 refresh, 407 signIn, 408 addAccount: signIn, 409 completeSignIn, 410 switchAccount, 411 removeAccount, 412 signOut, 413 signOutAll 414 }; 415}; 416 417export const getAuth = () => getContext<Auth>(AUTH_KEY);