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"; import { type AuthAccount, clearActive, dropAccount, loadAccounts, persistActive, readActiveDid, reconcileAccounts, saveAccounts, upsertAccount } from "./auth/accounts"; export const AUTH_KEY = Symbol("auth"); 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)}`; // local dev (localinfra) points these at the local pds/plc; defaults are the public network. const HANDLE_RESOLVER_URL = (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ?? "https://public.api.bsky.app"; const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( /\/+$/, "" ); const identityResolver = new LocalActorResolver({ handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}), web: new WebDidDocumentResolver() } }) }); let configured = false; export interface AuthProfile { did: Did; handle: string; } export interface CurrentUser { did: Did; handle: string; } export type { AuthAccount } from "./auth/accounts"; 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; readonly accounts: AuthAccount[]; bobbinUrl: string; refresh(): Promise; signIn(identifier: string, returnTo?: string): Promise; addAccount(identifier: string, returnTo?: string): Promise; completeSignIn(): Promise; switchAccount(did: Did): Promise; removeAccount(did: Did): Promise; signOut(): Promise; signOutAll(): Promise; } type MiniDoc = { did: Did; handle: string; pds?: 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 configure = () => { if (!browser || configured) return; configureOAuth({ metadata: { client_id: OAUTH_CLIENT_ID, redirect_uri: OAUTH_REDIRECT_URI }, identityResolver }); configured = true; }; 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, bobbinUrl: string ): Promise => { try { const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl); 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 }; } } catch { // try local resolver next. } try { const identity = await identityResolver.resolve(identifier as ActorIdentifier); return { did: identity.did as Did, handle: identity.handle }; } catch { return null; } }; const returnToFromState = (state: object | null): string => { if (state && typeof state === "object" && "returnTo" in state) { const returnTo = state.returnTo; if (typeof returnTo === "string") return returnTo; } return "/"; }; export const createAuth = ( bobbinUrl: string, initial?: { did: string; handle: string } | null ): Auth => { const seed = initial ?? null; let agent = $state(null); const bobbinUrlValue = bobbinUrl; 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); let accounts = $state([]); // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); saveAccounts(accounts); }; const resetLoggedOut = () => { clearActive(); agent = null; currentDid = null; profile = null; syncAccounts(); }; const hydrateProfile = async (did: Did) => { const resolved = await resolveProfile(did, bobbinUrl); profile = resolved ?? { did, handle: did }; const meta = upsertAccount(loadAccounts(), { did, handle: profile.handle, addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); accounts = reconcileAccounts(listStoredSessions(), meta); persistActive(did, profile.handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); agent = nextAgent; error = null; const did = nextAgent.sub as Did; currentDid = did; const known = loadAccounts().find((account) => account.did === did); persistActive(did, known?.handle ?? did); void hydrateProfile(did); }; // prune dead sessions when re-adoption fails. const activate = async (did: Did): Promise => { try { const session = await getSession(did, { allowStale: true }); adoptSession(session); return true; } catch (cause) { deleteStoredSession(did); saveAccounts(dropAccount(loadAccounts(), did)); error = errorMessage(cause); return false; } }; const refresh = async () => { if (!browser) return; configure(); error = null; syncAccounts(); const candidates: Did[] = []; for (const candidate of [readActiveDid(), currentDid, ...listStoredSessions()]) { if (candidate && !candidates.includes(candidate)) candidates.push(candidate); } for (const candidate of candidates) { if (await activate(candidate)) return; } resetLoggedOut(); }; 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 returnToFromState(state); } catch (cause) { error = errorMessage(cause); throw cause; } finally { authenticating = false; } }; const switchAccount = async (did: Did) => { if (!browser) return; configure(); error = null; if (!(await activate(did))) syncAccounts(); }; const removeAccount = async (did: Did) => { if (!browser) return; error = null; const wasActive = currentDid === did; try { if (wasActive && agent) { await agent.signOut(); } else { deleteStoredSession(did); } } catch { deleteStoredSession(did); } saveAccounts(dropAccount(loadAccounts(), did)); accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); if (wasActive) { const next = accounts[0]?.did ?? null; if (next) { await activate(next); } else { resetLoggedOut(); } } }; const signOut = async () => { if (currentDid) { await removeAccount(currentDid); } else { resetLoggedOut(); } }; const signOutAll = async () => { error = null; try { if (agent) await agent.signOut(); } catch { // remove local session state below. } if (browser) { for (const did of listStoredSessions()) deleteStoredSession(did); } saveAccounts([]); resetLoggedOut(); }; return { get agent() { return agent; }, get currentDid() { return currentDid; }, get profile() { return profile; }, get bobbinUrl() { return bobbinUrlValue; }, 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 }; }, get accounts() { return accounts; }, refresh, signIn, addAccount: signIn, completeSignIn, switchAccount, removeAccount, signOut, signOutAll }; }; export const getAuth = () => getContext(AUTH_KEY);