import { browser } from '$app/environment'; import { CompositeDidDocumentResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver'; import type { ActorIdentifier, Did } from '@atcute/lexicons/syntax'; import { OAuthUserAgent, configureOAuth, createAuthorizationUrl, deleteStoredSession, finalizeAuthorization, getSession, listStoredSessions } from '@atcute/oauth-browser-client'; import { getContext } from 'svelte'; import { SvelteURL, SvelteURLSearchParams } from 'svelte/reactivity'; import oauthMetadata from '../../static/oauth-client-metadata.json'; export const AUTH_KEY = Symbol('auth'); const CURRENT_DID_KEY = 'tangled.currentDid'; const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; const identityResolver = new LocalActorResolver({ handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() } }) }); let configured = false; export interface AuthProfile { did: Did; handle: string; avatar?: string; } export interface CurrentUser { did: Did; handle: string; avatar?: string; } export interface Auth { readonly agent: OAuthUserAgent | null; readonly currentDid: Did | null; readonly profile: AuthProfile | null; readonly error: string | null; readonly profileLoading: boolean; readonly authenticating: boolean; readonly currentUser: CurrentUser | null; refresh(): Promise; signIn(identifier: string, returnTo?: string): Promise; completeSignIn(): Promise; signOut(): Promise; } type MiniDoc = { did: Did; handle: string; pds?: string; avatar?: string; }; type OAuthSession = ConstructorParameters[0]; const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false; const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT ? DEV_CLIENT_ID : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT ? DEV_REDIRECT_URI : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; const APPVIEW_SERVICE = (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ?? DEFAULT_APPVIEW_SERVICE; const configure = () => { if (!browser || configured) return; configureOAuth({ metadata: { client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI }, identityResolver }); configured = true; }; const readStoredDid = (): Did | null => { if (!browser) return null; const value = localStorage.getItem(CURRENT_DID_KEY); return value?.startsWith('did:') ? (value as Did) : null; }; const persistAuth = (did: string, handle: string) => { if (!browser) return; localStorage.setItem(CURRENT_DID_KEY, did); localStorage.setItem(CURRENT_HANDLE_KEY, handle); const secure = location.protocol === 'https:' ? '; secure' : ''; const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; }; const clearAuth = () => { if (!browser) return; localStorage.removeItem(CURRENT_DID_KEY); localStorage.removeItem(CURRENT_HANDLE_KEY); document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; }; const errorMessage = (cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); return message.toLowerCase().includes('unknown state') ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.' : message; }; const resolveProfile = async (identifier: string): Promise => { try { const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE); url.searchParams.set('identifier', identifier); const response = await fetch(url, { headers: { accept: 'application/json' } }); if (response.ok) { const profile = (await response.json()) as MiniDoc; return { did: profile.did, handle: profile.handle, avatar: profile.avatar }; } } catch { // fall through to local resolution } try { const identity = await identityResolver.resolve(identifier as ActorIdentifier); return { did: identity.did as Did, handle: identity.handle }; } catch { return null; } }; export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { const seed = initial ?? null; let agent = $state(null); let currentDid = $state((seed?.did as Did | undefined) ?? null); let profile = $state( seed ? { did: seed.did as Did, handle: seed.handle } : null ); let error = $state(null); let authenticating = $state(false); const hydrateProfile = async (did: Did) => { const resolved = await resolveProfile(did); profile = resolved ?? { did, handle: did }; persistAuth(did, profile.handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); agent = nextAgent; const did = nextAgent.sub as Did; currentDid = did; if (browser) localStorage.setItem(CURRENT_DID_KEY, did); void hydrateProfile(did); }; const refresh = async () => { if (!browser) return; configure(); error = null; const preferred = readStoredDid() ?? currentDid ?? listStoredSessions()[0] ?? null; if (!preferred) { agent = null; currentDid = null; profile = null; clearAuth(); return; } try { const session = await getSession(preferred, { allowStale: true }); adoptSession(session); } catch (cause) { deleteStoredSession(preferred); clearAuth(); agent = null; currentDid = null; profile = null; error = errorMessage(cause); } }; const signIn = async (identifier: string, returnTo = '/') => { if (!browser) return; configure(); error = null; const trimmed = identifier.trim(); if (!trimmed) { error = 'Handle or DID required'; return; } if (location.hostname === 'localhost') { const url = new SvelteURL(location.href); url.hostname = '127.0.0.1'; url.searchParams.set('identifier', trimmed); url.searchParams.set('return_url', returnTo); location.replace(url); return; } try { const url = await createAuthorizationUrl({ target: { type: 'account', identifier: trimmed as ActorIdentifier }, scope: OAUTH_SCOPE, state: { returnTo } }); window.location.assign(url.toString()); } catch (cause) { error = errorMessage(cause); throw cause; } }; const completeSignIn = async () => { if (!browser) return '/'; configure(); error = null; authenticating = true; try { const params = new SvelteURLSearchParams(location.hash.slice(1)); history.replaceState(null, '', location.pathname + location.search); const { session, state } = await finalizeAuthorization(params); adoptSession(session); return typeof (state as { returnTo?: unknown } | null)?.returnTo === 'string' ? (state as { returnTo: string }).returnTo : '/'; } catch (cause) { error = errorMessage(cause); throw cause; } finally { authenticating = false; } }; const signOut = async () => { error = null; const did = currentDid; try { if (agent) { await agent.signOut(); } else if (did) { deleteStoredSession(did); } } catch { if (did) deleteStoredSession(did); } finally { clearAuth(); agent = null; currentDid = null; profile = null; } }; return { get agent() { return agent; }, get currentDid() { return currentDid; }, get profile() { return profile; }, get error() { return error; }, get profileLoading() { return currentDid !== null && profile === null; }, get authenticating() { return authenticating; }, get currentUser() { if (!currentDid) return null; return { did: currentDid, handle: profile?.handle ?? currentDid, avatar: profile?.avatar }; }, refresh, signIn, completeSignIn, signOut }; }; export const getAuth = () => getContext(AUTH_KEY);