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 403 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 DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; 36const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; 37const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; 38 39const identityResolver = new LocalActorResolver({ 40 handleResolver: new XrpcHandleResolver({ 41 serviceUrl: 'https://public.api.bsky.app' 42 }), 43 didDocumentResolver: new CompositeDidDocumentResolver({ 44 methods: { 45 plc: new PlcDidDocumentResolver(), 46 web: new WebDidDocumentResolver() 47 } 48 }) 49}); 50 51let configured = false; 52 53export interface AuthProfile { 54 did: Did; 55 handle: string; 56 avatar?: string; 57} 58 59export interface CurrentUser { 60 did: Did; 61 handle: string; 62 avatar?: string; 63} 64 65export type { AuthAccount } from './auth/accounts'; 66 67export interface Auth { 68 readonly agent: OAuthUserAgent | null; 69 readonly currentDid: Did | null; 70 readonly profile: AuthProfile | null; 71 readonly error: string | null; 72 readonly profileLoading: boolean; 73 readonly authenticating: boolean; 74 readonly currentUser: CurrentUser | null; 75 readonly accounts: AuthAccount[]; 76 refresh(): Promise<void>; 77 signIn(identifier: string, returnTo?: string): Promise<void>; 78 addAccount(identifier: string, returnTo?: string): Promise<void>; 79 completeSignIn(): Promise<string>; 80 switchAccount(did: Did): Promise<void>; 81 removeAccount(did: Did): Promise<void>; 82 signOut(): Promise<void>; 83 signOutAll(): Promise<void>; 84} 85 86type MiniDoc = { 87 did: Did; 88 handle: string; 89 pds?: string; 90 avatar?: string; 91}; 92 93type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; 94 95const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; 96const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false; 97const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT 98 ? DEV_CLIENT_ID 99 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); 100const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT 101 ? DEV_REDIRECT_URI 102 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); 103const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; 104const APPVIEW_SERVICE = 105 (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ?? 106 DEFAULT_APPVIEW_SERVICE; 107 108const configure = () => { 109 if (!browser || configured) return; 110 111 configureOAuth({ 112 metadata: { 113 client_id: OAUTH_CLIENT_ID, 114 redirect_uri: OAUTH_REDIRECT_URI 115 }, 116 identityResolver 117 }); 118 119 configured = true; 120}; 121 122const errorMessage = (cause: unknown) => { 123 const message = cause instanceof Error ? cause.message : String(cause); 124 return message.toLowerCase().includes('unknown state') 125 ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.' 126 : message; 127}; 128 129const resolveProfile = async (identifier: string): Promise<AuthProfile | null> => { 130 try { 131 const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE); 132 url.searchParams.set('identifier', identifier); 133 const response = await fetch(url, { headers: { accept: 'application/json' } }); 134 if (response.ok) { 135 const profile = (await response.json()) as MiniDoc; 136 return { 137 did: profile.did, 138 handle: profile.handle, 139 avatar: profile.avatar 140 }; 141 } 142 } catch { 143 // try local resolver next. 144 } 145 146 try { 147 const identity = await identityResolver.resolve(identifier as ActorIdentifier); 148 return { 149 did: identity.did as Did, 150 handle: identity.handle 151 }; 152 } catch { 153 return null; 154 } 155}; 156 157const 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 165export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { 166 const seed = initial ?? null; 167 let agent = $state<OAuthUserAgent | null>(null); 168 let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null); 169 let profile = $state<AuthProfile | null>( 170 seed ? { did: seed.did as Did, handle: seed.handle } : null 171 ); 172 let error = $state<string | null>(null); 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 }; 189 190 const hydrateProfile = async (did: Did) => { 191 const resolved = await resolveProfile(did); 192 profile = resolved ?? { did, handle: did }; 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); 202 }; 203 204 const adoptSession = (session: OAuthSession) => { 205 const nextAgent = new OAuthUserAgent(session); 206 agent = nextAgent; 207 error = null; 208 const did = nextAgent.sub as Did; 209 currentDid = did; 210 const known = loadAccounts().find((account) => account.did === did); 211 persistActive(did, known?.handle ?? did); 212 void hydrateProfile(did); 213 }; 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 229 const refresh = async () => { 230 if (!browser) return; 231 configure(); 232 error = null; 233 syncAccounts(); 234 235 const candidates: Did[] = []; 236 for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { 237 if (candidate && !candidates.includes(candidate)) candidates.push(candidate); 238 } 239 240 for (const candidate of candidates) { 241 if (await activate(candidate)) return; 242 } 243 244 resetLoggedOut(); 245 }; 246 247 const signIn = async (identifier: string, returnTo = '/') => { 248 if (!browser) return; 249 configure(); 250 error = null; 251 252 const trimmed = identifier.trim(); 253 if (!trimmed) { 254 error = 'Handle or DID required'; 255 return; 256 } 257 258 if (location.hostname === 'localhost') { 259 const url = new SvelteURL(location.href); 260 url.hostname = '127.0.0.1'; 261 url.searchParams.set('identifier', trimmed); 262 url.searchParams.set('return_url', returnTo); 263 location.replace(url); 264 return; 265 } 266 267 try { 268 const url = await createAuthorizationUrl({ 269 target: { 270 type: 'account', 271 identifier: trimmed as ActorIdentifier 272 }, 273 scope: OAUTH_SCOPE, 274 state: { returnTo } 275 }); 276 277 window.location.assign(url.toString()); 278 } catch (cause) { 279 error = errorMessage(cause); 280 throw cause; 281 } 282 }; 283 284 const completeSignIn = async () => { 285 if (!browser) return '/'; 286 configure(); 287 error = null; 288 authenticating = true; 289 290 try { 291 const params = new SvelteURLSearchParams(location.hash.slice(1)); 292 history.replaceState(null, '', location.pathname + location.search); 293 294 const { session, state } = await finalizeAuthorization(params); 295 adoptSession(session); 296 297 return returnToFromState(state); 298 } catch (cause) { 299 error = errorMessage(cause); 300 throw cause; 301 } finally { 302 authenticating = false; 303 } 304 }; 305 306 const switchAccount = async (did: Did) => { 307 if (!browser) return; 308 configure(); 309 error = null; 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; 317 try { 318 if (wasActive && agent) { 319 await agent.signOut(); 320 } else { 321 deleteStoredSession(did); 322 } 323 } catch { 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 } 337 } 338 }; 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 362 return { 363 get agent() { 364 return agent; 365 }, 366 get currentDid() { 367 return currentDid; 368 }, 369 get profile() { 370 return profile; 371 }, 372 get error() { 373 return error; 374 }, 375 get profileLoading() { 376 return currentDid !== null && profile === null; 377 }, 378 get authenticating() { 379 return authenticating; 380 }, 381 get currentUser() { 382 if (!currentDid) return null; 383 return { 384 did: currentDid, 385 handle: profile?.handle ?? currentDid, 386 avatar: profile?.avatar 387 }; 388 }, 389 get accounts() { 390 return accounts; 391 }, 392 refresh, 393 signIn, 394 addAccount: signIn, 395 completeSignIn, 396 switchAccount, 397 removeAccount, 398 signOut, 399 signOutAll 400 }; 401}; 402 403export const getAuth = () => getContext<Auth>(AUTH_KEY);