Monorepo for Tangled tangled.org
1

Configure Feed

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

core / web / src / lib / auth.svelte.ts
8.8 kB 331 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'; 22 23export const AUTH_KEY = Symbol('auth'); 24const CURRENT_DID_KEY = 'tangled.currentDid'; 25const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; 26const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; 27const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; 28const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; 29 30const identityResolver = new LocalActorResolver({ 31 handleResolver: new XrpcHandleResolver({ 32 serviceUrl: 'https://public.api.bsky.app' 33 }), 34 didDocumentResolver: new CompositeDidDocumentResolver({ 35 methods: { 36 plc: new PlcDidDocumentResolver(), 37 web: new WebDidDocumentResolver() 38 } 39 }) 40}); 41 42let configured = false; 43 44export interface AuthProfile { 45 did: Did; 46 handle: string; 47 avatar?: string; 48} 49 50export interface CurrentUser { 51 did: Did; 52 handle: string; 53 avatar?: string; 54} 55 56export interface Auth { 57 readonly agent: OAuthUserAgent | null; 58 readonly currentDid: Did | null; 59 readonly profile: AuthProfile | null; 60 readonly error: string | null; 61 readonly profileLoading: boolean; 62 readonly authenticating: boolean; 63 readonly currentUser: CurrentUser | null; 64 refresh(): Promise<void>; 65 signIn(identifier: string, returnTo?: string): Promise<void>; 66 completeSignIn(): Promise<string>; 67 signOut(): Promise<void>; 68} 69 70type MiniDoc = { 71 did: Did; 72 handle: string; 73 pds?: string; 74 avatar?: string; 75}; 76 77type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; 78 79const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; 80const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false; 81const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT 82 ? DEV_CLIENT_ID 83 : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); 84const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT 85 ? DEV_REDIRECT_URI 86 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); 87const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; 88const APPVIEW_SERVICE = 89 (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ?? 90 DEFAULT_APPVIEW_SERVICE; 91 92const configure = () => { 93 if (!browser || configured) return; 94 95 configureOAuth({ 96 metadata: { 97 client_id: OAUTH_CLIENT_ID, 98 redirect_uri: OAUTH_REDIRECT_URI 99 }, 100 identityResolver 101 }); 102 103 configured = true; 104}; 105 106const 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 112const 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 122const 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 130const errorMessage = (cause: unknown) => { 131 const message = cause instanceof Error ? cause.message : String(cause); 132 return message.toLowerCase().includes('unknown state') 133 ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.' 134 : message; 135}; 136 137const resolveProfile = async (identifier: string): Promise<AuthProfile | null> => { 138 try { 139 const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE); 140 url.searchParams.set('identifier', identifier); 141 const response = await fetch(url, { headers: { accept: 'application/json' } }); 142 if (response.ok) { 143 const profile = (await response.json()) as MiniDoc; 144 return { 145 did: profile.did, 146 handle: profile.handle, 147 avatar: profile.avatar 148 }; 149 } 150 } catch { 151 // fall through to local resolution 152 } 153 154 try { 155 const identity = await identityResolver.resolve(identifier as ActorIdentifier); 156 return { 157 did: identity.did as Did, 158 handle: identity.handle 159 }; 160 } catch { 161 return null; 162 } 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 175 const hydrateProfile = async (did: Did) => { 176 const resolved = await resolveProfile(did); 177 profile = resolved ?? { did, handle: did }; 178 persistAuth(did, profile.handle); 179 }; 180 181 const adoptSession = (session: OAuthSession) => { 182 const nextAgent = new OAuthUserAgent(session); 183 agent = nextAgent; 184 const did = nextAgent.sub as Did; 185 currentDid = did; 186 if (browser) localStorage.setItem(CURRENT_DID_KEY, did); 187 void hydrateProfile(did); 188 }; 189 190 const refresh = async () => { 191 if (!browser) return; 192 configure(); 193 error = null; 194 195 const preferred = readStoredDid() ?? currentDid ?? listStoredSessions()[0] ?? null; 196 if (!preferred) { 197 agent = null; 198 currentDid = null; 199 profile = null; 200 clearAuth(); 201 return; 202 } 203 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); 214 } 215 }; 216 217 const signIn = async (identifier: string, returnTo = '/') => { 218 if (!browser) return; 219 configure(); 220 error = null; 221 222 const trimmed = identifier.trim(); 223 if (!trimmed) { 224 error = 'Handle or DID required'; 225 return; 226 } 227 228 if (location.hostname === 'localhost') { 229 const url = new SvelteURL(location.href); 230 url.hostname = '127.0.0.1'; 231 url.searchParams.set('identifier', trimmed); 232 url.searchParams.set('return_url', returnTo); 233 location.replace(url); 234 return; 235 } 236 237 try { 238 const url = await createAuthorizationUrl({ 239 target: { 240 type: 'account', 241 identifier: trimmed as ActorIdentifier 242 }, 243 scope: OAUTH_SCOPE, 244 state: { returnTo } 245 }); 246 247 window.location.assign(url.toString()); 248 } catch (cause) { 249 error = errorMessage(cause); 250 throw cause; 251 } 252 }; 253 254 const completeSignIn = async () => { 255 if (!browser) return '/'; 256 configure(); 257 error = null; 258 authenticating = true; 259 260 try { 261 const params = new SvelteURLSearchParams(location.hash.slice(1)); 262 history.replaceState(null, '', location.pathname + location.search); 263 264 const { session, state } = await finalizeAuthorization(params); 265 adoptSession(session); 266 267 return typeof (state as { returnTo?: unknown } | null)?.returnTo === 'string' 268 ? (state as { returnTo: string }).returnTo 269 : '/'; 270 } catch (cause) { 271 error = errorMessage(cause); 272 throw cause; 273 } finally { 274 authenticating = false; 275 } 276 }; 277 278 const signOut = async () => { 279 error = null; 280 const did = currentDid; 281 try { 282 if (agent) { 283 await agent.signOut(); 284 } else if (did) { 285 deleteStoredSession(did); 286 } 287 } catch { 288 if (did) deleteStoredSession(did); 289 } finally { 290 clearAuth(); 291 agent = null; 292 currentDid = null; 293 profile = null; 294 } 295 }; 296 297 return { 298 get agent() { 299 return agent; 300 }, 301 get currentDid() { 302 return currentDid; 303 }, 304 get profile() { 305 return profile; 306 }, 307 get error() { 308 return error; 309 }, 310 get profileLoading() { 311 return currentDid !== null && profile === null; 312 }, 313 get authenticating() { 314 return authenticating; 315 }, 316 get currentUser() { 317 if (!currentDid) return null; 318 return { 319 did: currentDid, 320 handle: profile?.handle ?? currentDid, 321 avatar: profile?.avatar 322 }; 323 }, 324 refresh, 325 signIn, 326 completeSignIn, 327 signOut 328 }; 329}; 330 331export const getAuth = () => getContext<Auth>(AUTH_KEY);