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