This repository has no description
0

Configure Feed

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

core / web / src / lib / auth.svelte.ts
12 kB 453 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 "./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} 63 64export interface CurrentUser { 65 did: Did; 66 handle: string; 67} 68 69export type AuthState = 70 | { kind: "logged-out" } 71 | { kind: "loading"; did: Did | null; profile: AuthProfile | null } 72 | { kind: "authenticating" } 73 | { kind: "profile-loading"; agent: OAuthUserAgent; did: Did } 74 | { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile } 75 | { 76 kind: "failed"; 77 message: string; 78 agent?: OAuthUserAgent; 79 did?: Did; 80 profile?: AuthProfile; 81 }; 82 83export type { AuthAccount } from "./auth/accounts"; 84 85export interface Auth { 86 readonly state: AuthState; 87 readonly agent: OAuthUserAgent | null; 88 readonly currentDid: Did | null; 89 readonly profile: AuthProfile | null; 90 readonly error: string | null; 91 readonly profileLoading: boolean; 92 readonly authenticating: boolean; 93 readonly currentUser: CurrentUser | null; 94 readonly accounts: AuthAccount[]; 95 bobbinUrl: string; 96 refresh(): Promise<void>; 97 signIn(identifier: string, returnTo?: string): Promise<void>; 98 addAccount(identifier: string, returnTo?: string): Promise<void>; 99 completeSignIn(): Promise<string>; 100 switchAccount(did: Did): Promise<void>; 101 removeAccount(did: Did): Promise<void>; 102 signOut(): Promise<void>; 103 signOutAll(): Promise<void>; 104} 105 106type MiniDoc = { 107 did: Did; 108 handle: string; 109 pds?: string; 110}; 111 112type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; 113 114const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; 115const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false; 116const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT 117 ? DEV_CLIENT_ID 118 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); 119const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT 120 ? DEV_REDIRECT_URI 121 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); 122const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; 123 124const configure = () => { 125 if (!browser || configured) return; 126 127 configureOAuth({ 128 metadata: { 129 client_id: OAUTH_CLIENT_ID, 130 redirect_uri: OAUTH_REDIRECT_URI 131 }, 132 identityResolver 133 }); 134 135 configured = true; 136}; 137 138const errorMessage = (cause: unknown) => { 139 const message = cause instanceof Error ? cause.message : String(cause); 140 return message.toLowerCase().includes("unknown state") 141 ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost." 142 : message; 143}; 144 145const resolveProfile = async ( 146 identifier: string, 147 bobbinUrl: string 148): Promise<AuthProfile | null> => { 149 try { 150 const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl); 151 url.searchParams.set("identifier", identifier); 152 const response = await fetch(url, { headers: { accept: "application/json" } }); 153 if (response.ok) { 154 const profile = (await response.json()) as MiniDoc; 155 return { 156 did: profile.did, 157 handle: profile.handle 158 }; 159 } 160 } catch { 161 // try local resolver next. 162 } 163 164 try { 165 const identity = await identityResolver.resolve(identifier as ActorIdentifier); 166 return { 167 did: identity.did as Did, 168 handle: identity.handle 169 }; 170 } catch { 171 return null; 172 } 173}; 174 175const returnToFromState = (state: object | null): string => { 176 if (state && typeof state === "object" && "returnTo" in state) { 177 const returnTo = state.returnTo; 178 if (typeof returnTo === "string") return returnTo; 179 } 180 return "/"; 181}; 182 183export const createAuth = ( 184 bobbinUrl: string, 185 initial?: { did: string; handle: string } | null 186): Auth => { 187 const seed = initial ?? null; 188 const bobbinUrlValue = bobbinUrl; 189 let state = $state<AuthState>( 190 seed 191 ? { 192 kind: "loading", 193 did: seed.did as Did, 194 profile: { did: seed.did as Did, handle: seed.handle } 195 } 196 : { kind: "logged-out" } 197 ); 198 let accounts = $state<AuthAccount[]>([]); 199 200 // merge atcute's stored sessions with persisted account metadata. 201 const syncAccounts = () => { 202 accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); 203 saveAccounts(accounts); 204 }; 205 206 const currentAgent = (): OAuthUserAgent | null => 207 "agent" in state ? (state.agent ?? null) : null; 208 const currentDid = (): Did | null => 209 "did" in state 210 ? (state.did ?? null) 211 : state.kind === "authenticated" 212 ? state.profile.did 213 : null; 214 const currentProfile = (): AuthProfile | null => 215 "profile" in state ? (state.profile ?? null) : null; 216 const failureState = (message: string): AuthState => ({ 217 kind: "failed", 218 message, 219 agent: currentAgent() ?? undefined, 220 did: currentDid() ?? undefined, 221 profile: currentProfile() ?? undefined 222 }); 223 224 const resetLoggedOut = (message?: string) => { 225 clearActive(); 226 state = message ? { kind: "failed", message } : { kind: "logged-out" }; 227 syncAccounts(); 228 }; 229 230 const hydrateProfile = async (did: Did, nextAgent: OAuthUserAgent) => { 231 const resolved = await resolveProfile(did, bobbinUrl); 232 if (state.kind !== "profile-loading" || state.agent !== nextAgent) return; 233 const nextProfile = resolved ?? { did, handle: did }; 234 state = { kind: "authenticated", agent: nextAgent, profile: nextProfile }; 235 const meta = upsertAccount(loadAccounts(), { 236 did, 237 handle: nextProfile.handle, 238 addedAt: Math.floor(Date.now() / 1000) 239 }); 240 saveAccounts(meta); 241 accounts = reconcileAccounts(listStoredSessions(), meta); 242 persistActive(did, nextProfile.handle); 243 }; 244 245 const adoptSession = (session: OAuthSession) => { 246 const nextAgent = new OAuthUserAgent(session); 247 const did = nextAgent.sub as Did; 248 state = { kind: "profile-loading", agent: nextAgent, did }; 249 const known = loadAccounts().find((account) => account.did === did); 250 persistActive(did, known?.handle ?? did); 251 void hydrateProfile(did, nextAgent); 252 }; 253 254 // prune dead sessions when re-adoption fails. 255 const activate = async (did: Did): Promise<boolean> => { 256 try { 257 const session = await getSession(did, { allowStale: true }); 258 adoptSession(session); 259 return true; 260 } catch (cause) { 261 deleteStoredSession(did); 262 saveAccounts(dropAccount(loadAccounts(), did)); 263 state = failureState(errorMessage(cause)); 264 return false; 265 } 266 }; 267 268 const refresh = async () => { 269 if (!browser) return; 270 configure(); 271 const previousDid = currentDid(); 272 const previousProfile = currentProfile(); 273 state = { kind: "loading", did: previousDid, profile: previousProfile }; 274 syncAccounts(); 275 276 const candidates: Did[] = []; 277 for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { 278 if (candidate && !candidates.includes(candidate)) candidates.push(candidate); 279 } 280 281 let lastFailure: string | undefined; 282 for (const candidate of candidates) { 283 if (await activate(candidate)) return; 284 const nextState = state as AuthState; 285 if (nextState.kind === "failed") lastFailure = nextState.message; 286 } 287 288 resetLoggedOut(lastFailure); 289 }; 290 291 const signIn = async (identifier: string, returnTo = "/") => { 292 if (!browser) return; 293 configure(); 294 295 const trimmed = identifier.trim(); 296 if (!trimmed) { 297 state = failureState("Handle or DID required"); 298 return; 299 } 300 301 if (location.hostname === "localhost") { 302 const url = new SvelteURL(location.href); 303 url.hostname = "127.0.0.1"; 304 url.searchParams.set("identifier", trimmed); 305 url.searchParams.set("return_url", returnTo); 306 location.replace(url); 307 return; 308 } 309 310 try { 311 const url = await createAuthorizationUrl({ 312 target: { 313 type: "account", 314 identifier: trimmed as ActorIdentifier 315 }, 316 scope: OAUTH_SCOPE, 317 state: { returnTo } 318 }); 319 320 window.location.assign(url.toString()); 321 } catch (cause) { 322 state = failureState(errorMessage(cause)); 323 throw cause; 324 } 325 }; 326 327 const completeSignIn = async () => { 328 if (!browser) return "/"; 329 configure(); 330 state = { kind: "authenticating" }; 331 332 try { 333 const params = new SvelteURLSearchParams(location.hash.slice(1)); 334 history.replaceState(null, "", location.pathname + location.search); 335 336 const { session, state } = await finalizeAuthorization(params); 337 adoptSession(session); 338 339 return returnToFromState(state); 340 } catch (cause) { 341 state = failureState(errorMessage(cause)); 342 throw cause; 343 } 344 }; 345 346 const switchAccount = async (did: Did) => { 347 if (!browser) return; 348 configure(); 349 if (!(await activate(did))) syncAccounts(); 350 }; 351 352 const removeAccount = async (did: Did) => { 353 if (!browser) return; 354 const wasActive = currentDid() === did; 355 try { 356 const activeAgent = currentAgent(); 357 if (wasActive && activeAgent) { 358 await activeAgent.signOut(); 359 } else { 360 deleteStoredSession(did); 361 } 362 } catch { 363 deleteStoredSession(did); 364 } 365 366 saveAccounts(dropAccount(loadAccounts(), did)); 367 accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); 368 369 if (wasActive) { 370 const next = accounts[0]?.did ?? null; 371 if (next) { 372 await activate(next); 373 } else { 374 resetLoggedOut(); 375 } 376 } 377 }; 378 379 const signOut = async () => { 380 const did = currentDid(); 381 if (did) { 382 await removeAccount(did); 383 } else { 384 resetLoggedOut(); 385 } 386 }; 387 388 const signOutAll = async () => { 389 try { 390 const activeAgent = currentAgent(); 391 if (activeAgent) await activeAgent.signOut(); 392 } catch { 393 // remove local session state below. 394 } 395 if (browser) { 396 for (const did of listStoredSessions()) deleteStoredSession(did); 397 } 398 saveAccounts([]); 399 resetLoggedOut(); 400 }; 401 402 return { 403 get state() { 404 return state; 405 }, 406 get agent() { 407 return currentAgent(); 408 }, 409 get currentDid() { 410 return currentDid(); 411 }, 412 get profile() { 413 return currentProfile(); 414 }, 415 get bobbinUrl() { 416 return bobbinUrlValue; 417 }, 418 get error() { 419 return state.kind === "failed" ? state.message : null; 420 }, 421 get profileLoading() { 422 return ( 423 state.kind === "profile-loading" || 424 (state.kind === "loading" && state.did !== null && state.profile === null) 425 ); 426 }, 427 get authenticating() { 428 return state.kind === "authenticating"; 429 }, 430 get currentUser() { 431 const did = currentDid(); 432 if (!did) return null; 433 const profile = currentProfile(); 434 return { 435 did, 436 handle: profile?.handle ?? did 437 }; 438 }, 439 get accounts() { 440 return accounts; 441 }, 442 refresh, 443 signIn, 444 addAccount: signIn, 445 completeSignIn, 446 switchAccount, 447 removeAccount, 448 signOut, 449 signOutAll 450 }; 451}; 452 453export const getAuth = () => getContext<Auth>(AUTH_KEY);