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 "./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 AuthState = | { kind: "logged-out" } | { kind: "loading"; did: Did | null; profile: AuthProfile | null } | { kind: "authenticating" } | { kind: "profile-loading"; agent: OAuthUserAgent; did: Did } | { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile } | { kind: "failed"; message: string; agent?: OAuthUserAgent; did?: Did; profile?: AuthProfile; }; export type { AuthAccount } from "./auth/accounts"; export interface Auth { readonly state: AuthState; 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; const bobbinUrlValue = bobbinUrl; let state = $state( seed ? { kind: "loading", did: seed.did as Did, profile: { did: seed.did as Did, handle: seed.handle } } : { kind: "logged-out" } ); let accounts = $state([]); // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); saveAccounts(accounts); }; const currentAgent = (): OAuthUserAgent | null => "agent" in state ? (state.agent ?? null) : null; const currentDid = (): Did | null => "did" in state ? (state.did ?? null) : state.kind === "authenticated" ? state.profile.did : null; const currentProfile = (): AuthProfile | null => "profile" in state ? (state.profile ?? null) : null; const failureState = (message: string): AuthState => ({ kind: "failed", message, agent: currentAgent() ?? undefined, did: currentDid() ?? undefined, profile: currentProfile() ?? undefined }); const resetLoggedOut = (message?: string) => { clearActive(); state = message ? { kind: "failed", message } : { kind: "logged-out" }; syncAccounts(); }; const hydrateProfile = async (did: Did, nextAgent: OAuthUserAgent) => { const resolved = await resolveProfile(did, bobbinUrl); if (state.kind !== "profile-loading" || state.agent !== nextAgent) return; const nextProfile = resolved ?? { did, handle: did }; state = { kind: "authenticated", agent: nextAgent, profile: nextProfile }; const meta = upsertAccount(loadAccounts(), { did, handle: nextProfile.handle, addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); accounts = reconcileAccounts(listStoredSessions(), meta); persistActive(did, nextProfile.handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); const did = nextAgent.sub as Did; state = { kind: "profile-loading", agent: nextAgent, did }; const known = loadAccounts().find((account) => account.did === did); persistActive(did, known?.handle ?? did); void hydrateProfile(did, nextAgent); }; // 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)); state = failureState(errorMessage(cause)); return false; } }; const refresh = async () => { if (!browser) return; configure(); const previousDid = currentDid(); const previousProfile = currentProfile(); state = { kind: "loading", did: previousDid, profile: previousProfile }; syncAccounts(); const candidates: Did[] = []; for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { if (candidate && !candidates.includes(candidate)) candidates.push(candidate); } let lastFailure: string | undefined; for (const candidate of candidates) { if (await activate(candidate)) return; const nextState = state as AuthState; if (nextState.kind === "failed") lastFailure = nextState.message; } resetLoggedOut(lastFailure); }; const signIn = async (identifier: string, returnTo = "/") => { if (!browser) return; configure(); const trimmed = identifier.trim(); if (!trimmed) { state = failureState("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) { state = failureState(errorMessage(cause)); throw cause; } }; const completeSignIn = async () => { if (!browser) return "/"; configure(); state = { kind: "authenticating" }; 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) { state = failureState(errorMessage(cause)); throw cause; } }; const switchAccount = async (did: Did) => { if (!browser) return; configure(); if (!(await activate(did))) syncAccounts(); }; const removeAccount = async (did: Did) => { if (!browser) return; const wasActive = currentDid() === did; try { const activeAgent = currentAgent(); if (wasActive && activeAgent) { await activeAgent.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 () => { const did = currentDid(); if (did) { await removeAccount(did); } else { resetLoggedOut(); } }; const signOutAll = async () => { try { const activeAgent = currentAgent(); if (activeAgent) await activeAgent.signOut(); } catch { // remove local session state below. } if (browser) { for (const did of listStoredSessions()) deleteStoredSession(did); } saveAccounts([]); resetLoggedOut(); }; return { get state() { return state; }, get agent() { return currentAgent(); }, get currentDid() { return currentDid(); }, get profile() { return currentProfile(); }, get bobbinUrl() { return bobbinUrlValue; }, get error() { return state.kind === "failed" ? state.message : null; }, get profileLoading() { return ( state.kind === "profile-loading" || (state.kind === "loading" && state.did !== null && state.profile === null) ); }, get authenticating() { return state.kind === "authenticating"; }, get currentUser() { const did = currentDid(); if (!did) return null; const profile = currentProfile(); return { did, handle: profile?.handle ?? did }; }, get accounts() { return accounts; }, refresh, signIn, addAccount: signIn, completeSignIn, switchAccount, removeAccount, signOut, signOutAll }; }; export const getAuth = () => getContext(AUTH_KEY);