import { browser } from "$app/env"; import { CompositeDidDocumentResolver, DidNotFoundError, FailedHandleResolutionError, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver } 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, untrack } from "svelte"; import { SvelteMap, SvelteURL, SvelteURLSearchParams } from "svelte/reactivity"; import oauthMetadata, { loopbackClientId } from "#lib/oauth-client-metadata.ts"; import { type AuthAccount, clearActive, dropAccount, loadAccounts, loadOrgDids, persistActive, readActiveDid, reconcileAccounts, saveAccounts, upsertAccount } from "#lib/auth/accounts.ts"; import { revokeAllOrgSessions, revokeOrgSessionsFor } from "#lib/auth/orgSessions.ts"; import { consumePendingCallback, writePendingLogin } from "#lib/auth/pending.ts"; import { safeReturnTo } from "#lib/auth/returnTo.ts"; import { isDeadSessionError } from "#lib/auth/session.ts"; import { handleResolverFor, handleResolverUrls } from "#lib/auth/handleResolver.ts"; import { sentence } from "#lib/format.ts"; import { normalizeHandle } from "#lib/handle.ts"; export const AUTH_KEY = Symbol("auth"); const DEV_REDIRECT_URI = `http://127.0.0.1:${import.meta.env.VITE_DEV_PORT ?? "5173"}/oauth/callback`; // local dev (localinfra) points these at the local pds/plc; defaults are the public network. const HANDLE_RESOLVER_URLS = handleResolverUrls( import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined ); const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( /\/+$/, "" ); // set by createAuth from the web config let handleBobbinUrl = ""; export const handleResolver = handleResolverFor(HANDLE_RESOLVER_URLS, () => handleBobbinUrl); const identityResolver = new LocalActorResolver({ handleResolver, 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; profile: AuthProfile | null } | { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile } | { kind: "failed"; message: string; agent?: OAuthUserAgent; did?: Did; profile?: AuthProfile; }; export type { AuthAccount } from "#lib/auth/accounts.ts"; export interface AgentOptions { // a dead session sends the browser off to the pds, so only ask for this from // something the user clicked relogin?: boolean; } export interface Auth { readonly state: AuthState; readonly agent: OAuthUserAgent | null; readonly currentDid: Did | null; readonly profile: AuthProfile | null; readonly error: string | null; readonly resolving: boolean; readonly agentPending: boolean; readonly currentUser: CurrentUser | null; readonly accounts: AuthAccount[]; hasAccount(did: string): boolean; bobbinUrl: string; refresh(): Promise; adoptHandle(handle: string): void; signIn(identifier: string, returnTo?: string): Promise; addAccount(identifier: string, returnTo?: string): Promise; startAuthorization(serviceUrl: string, returnTo?: string): Promise; completeAuthorization(did: Did, response: AuthorizationResponse): Promise; completeSignIn(): Promise; completePendingSignIn(hash: string): Promise; switchAccount(did: Did, returnTo?: string): Promise; agentFor(did: Did, options?: AgentOptions): Promise; removeAccount(did: Did): Promise; signOut(): Promise; signOutAll(): Promise; } export type AuthorizationResponse = Record<"code" | "state" | "iss", string>; type MiniDoc = { did: Did; handle: string; pds?: string; }; type OAuthSession = ConstructorParameters[0]; const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; const DEV_CLIENT_ID = loopbackClientId(DEV_REDIRECT_URI, OAUTH_SCOPE); 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 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 signing in from ${new URL(DEV_REDIRECT_URI).origin} instead of localhost.` : sentence(message); }; export const refreshSessionCandidates = ( activeDid: Did | null, currentDid: Did | null, storedDids: readonly Did[], orgDids: readonly Did[] ): Did[] => { const candidates: Did[] = []; for (const candidate of [activeDid, currentDid, ...storedDids]) { if (candidate && !orgDids.includes(candidate) && !candidates.includes(candidate)) { candidates.push(candidate); } } return candidates; }; const resolveProfile = async ( identifier: string, bobbinUrl: string ): Promise => { try { const url = new URL("/xrpc/blue.microcosm.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: normalizeHandle(profile.handle) }; } } catch { // try local resolver next. } try { const identity = await identityResolver.resolve(identifier as ActorIdentifier); return { did: identity.did as Did, handle: normalizeHandle(identity.handle) }; } catch { return null; } }; const causeChain = function* (cause: unknown): Generator { for (let current = cause; current !== undefined;) { yield current; current = current instanceof Error ? current.cause : undefined; } }; const signInErrorMessage = (cause: unknown) => { for (const link of causeChain(cause)) { if (link instanceof DidNotFoundError) { return "No account uses that handle. Check the spelling and try again."; } if (link instanceof FailedHandleResolutionError) { return "Unable to look up that handle. Check your connection and try again."; } } return `${errorMessage(cause)} Try again.`; }; const returnToFromState = (state: object | null): string => { if (state && typeof state === "object" && "returnTo" in state) { return safeReturnTo(state.returnTo); } return "/"; }; export const createAuth = ( bobbinUrl: string, initial?: { did: string; handle: string } | null ): Auth => { const seed = initial ?? null; const bobbinUrlValue = bobbinUrl; handleBobbinUrl = 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([]); const otherAgents = new SvelteMap(); let pendingExchange: Promise | null = null; // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { accounts = reconcileAccounts( browser ? listStoredSessions() : [], loadAccounts(), loadOrgDids() ); 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; // startup swaps state several times for one account. readers rerun only when these change const activeDid = $derived(currentDid()); const activeAgent = $derived(currentAgent()); 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 ?? state.profile ?? { 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, loadOrgDids()); persistActive(did, nextProfile.handle); }; const adoptHandle = (handle: string) => { if (state.kind !== "authenticated") return; const did = state.profile.did; state = { ...state, profile: { ...state.profile, handle } }; const meta = upsertAccount(loadAccounts(), { did, handle, addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); accounts = reconcileAccounts(listStoredSessions(), meta, loadOrgDids()); persistActive(did, handle); }; const adoptSession = (session: OAuthSession) => { const nextAgent = new OAuthUserAgent(session); const did = nextAgent.sub as Did; otherAgents.delete(did); const previous = currentProfile(); const known = loadAccounts().find((account) => account.did === did); const handle = known?.handle ?? (previous?.did === did ? previous.handle : null); state = { kind: "profile-loading", agent: nextAgent, did, profile: handle ? { did, handle } : null }; persistActive(did, handle ?? did); syncAccounts(); void hydrateProfile(did, nextAgent); }; const forgetSession = (did: Did) => { otherAgents.delete(did); deleteStoredSession(did); saveAccounts(dropAccount(loadAccounts(), did)); // the controller is gone, its org sessions have no one left to act for void revokeOrgSessionsFor(did); }; const activate = async (did: Did): Promise => { try { const session = await getSession(did); adoptSession(session); return true; } catch (cause) { if (isDeadSessionError(cause)) { forgetSession(did); return false; } state = failureState(errorMessage(cause)); return false; } }; // allowStale would let a dead session through, the write then fails with the raw pds error const agentFor = async ( did: Did, { relogin = true }: AgentOptions = {} ): Promise => { configure(); // a sign-in exchange may still be storing this session if (pendingExchange) await pendingExchange.catch(() => {}); // agentFor caches into otherAgents. tracking it would rerun the effect that called agentFor const cached = untrack(() => { const active = currentAgent(); return active?.sub === did ? active : (otherAgents.get(did) ?? null); }); try { if (cached) { await cached.getSession(); return cached; } const agent = new OAuthUserAgent(await getSession(did)); otherAgents.set(did, agent); return agent; } catch (cause) { if (!isDeadSessionError(cause)) throw cause; const handle = accounts.find((account) => account.did === did)?.handle ?? did; if (currentDid() === did) { await removeAccount(did); } else { forgetSession(did); syncAccounts(); } if (browser && relogin) { try { await signIn(did, location.pathname + location.search); // the page is navigating away, stay pending so the form keeps its spinner return new Promise(() => {}); } catch { // fall through to the inline error } } throw new Error(`${handle}'s session expired. Sign in again to act as them.`, { cause }); } }; const refresh = async () => { if (!browser) return; configure(); const previousDid = currentDid(); const previousProfile = currentProfile(); state = { kind: "loading", did: previousDid, profile: previousProfile }; syncAccounts(); const candidates = refreshSessionCandidates( readActiveDid(), currentDid(), listStoredSessions(), loadOrgDids() ); 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, rawReturnTo = "/") => { if (!browser) return; configure(); const returnTo = safeReturnTo(rawReturnTo); // what people paste from bsky.app carries bidi marks and an `@` const trimmed = normalizeHandle(identifier); if (!trimmed) { state = failureState("Enter a handle or DID."); 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 { // createAuthorizationUrl resolves too but never returns it, and the // callback needs the did to seed cookies const [url, profile] = await Promise.all([ createAuthorizationUrl({ target: { type: "account", identifier: trimmed as ActorIdentifier }, scope: OAUTH_SCOPE, state: { returnTo } }), resolveProfile(trimmed, bobbinUrlValue) ]); if (profile) writePendingLogin({ did: profile.did, handle: profile.handle, returnTo }); window.location.assign(url.toString()); } catch (cause) { state = failureState(signInErrorMessage(cause)); throw cause; } }; const reportSignIn = () => { void fetch("/_internal/analytics/signin", { method: "POST", keepalive: true }).catch( () => {} ); }; const startAuthorization = async (serviceUrl: string, rawReturnTo = "/") => { configure(); const url = await createAuthorizationUrl({ target: { type: "pds", serviceUrl }, scope: OAUTH_SCOPE, state: { returnTo: safeReturnTo(rawReturnTo) } }); const requestUri = url.searchParams.get("request_uri"); if (!requestUri) throw new Error("The PDS did not return a request_uri."); return requestUri; }; // the request carried no login hint, so only the token's sub says whose session this is const completeAuthorization = async (did: Did, response: AuthorizationResponse) => { configure(); const { session } = await finalizeAuthorization(new SvelteURLSearchParams(response)); if (session.info.sub !== did) { deleteStoredSession(session.info.sub); throw new Error("The PDS signed in a different account than the one just created."); } adoptSession(session); reportSignIn(); }; 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); reportSignIn(); return returnToFromState(state); } catch (cause) { state = failureState(errorMessage(cause)); throw cause; } }; // no authenticating flip, the seeded profile keeps the topbar settled const completePendingSignIn = async (hash: string): Promise => { configure(); const params = new SvelteURLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash); const exchange = finalizeAuthorization(params); pendingExchange = exchange; try { const { session } = await exchange; adoptSession(session); reportSignIn(); } catch (cause) { // the callback already seeded cookies for a login that didn't happen resetLoggedOut(); throw cause; } finally { pendingExchange = null; } }; const switchAccount = async (did: Did, returnTo = "/") => { if (!browser) return false; configure(); if (await activate(did)) return true; syncAccounts(); await signIn(did, returnTo); return false; }; const removeAccount = async (did: Did) => { if (!browser) return; const wasActive = currentDid() === did; try { const agent = wasActive ? currentAgent() : (otherAgents.get(did) ?? null); if (agent) { await agent.signOut(); } else { deleteStoredSession(did); } } catch { deleteStoredSession(did); } otherAgents.delete(did); await revokeOrgSessionsFor(did); saveAccounts(dropAccount(loadAccounts(), did)); accounts = reconcileAccounts(listStoredSessions(), loadAccounts(), loadOrgDids()); if (wasActive) { for (const account of accounts) { if (await activate(account.did)) return; } resetLoggedOut(); } }; const signOut = async () => { const did = currentDid(); if (did) { await removeAccount(did); } else { resetLoggedOut(); } }; const signOutAll = async () => { const agents = [currentAgent(), ...otherAgents.values()].filter((agent) => agent !== null); await Promise.allSettled([ ...agents.map((agent) => agent.signOut()), revokeAllOrgSessions() ]); otherAgents.clear(); if (browser) { for (const did of listStoredSessions()) deleteStoredSession(did); } saveAccounts([]); resetLoggedOut(); }; const auth: Auth = { get state() { return state; }, get agent() { return activeAgent; }, get currentDid() { return activeDid; }, get profile() { return currentProfile(); }, get bobbinUrl() { return bobbinUrlValue; }, get error() { return state.kind === "failed" ? state.message : null; }, get resolving() { return ( state.kind === "authenticating" || (state.kind === "profile-loading" && state.profile === null) || (state.kind === "loading" && state.did === null) ); }, get agentPending() { return ( state.kind === "authenticating" || state.kind === "loading" || (state.kind === "profile-loading" && state.profile === null) ); }, get currentUser() { const did = currentDid(); if (!did) return null; const profile = currentProfile(); return { did, handle: profile?.handle ?? did }; }, get accounts() { return accounts; }, hasAccount: (did: string) => accounts.some((account) => account.did === did), agentFor, refresh, adoptHandle, signIn, addAccount: signIn, startAuthorization, completeAuthorization, completeSignIn, completePendingSignIn, switchAccount, removeAccount, signOut, signOutAll }; if (browser) browserAuth = auth; return auth; }; // load functions run outside component context, the browser only ever has one auth let browserAuth: Auth | null = null; export const getBrowserAuth = (): Auth | null => browserAuth; export const getAuth = () => getContext(AUTH_KEY); export interface ResumedSignIn { handle: string; returnTo: string; done: Promise; } // the layout calls this while it initialises. child effects mount before its // onMount, and one that asks for an agent before the exchange finishes sees a // revoked session and starts oauth over export const resumeSignIn = (auth: Auth, pathname: string): ResumedSignIn | null => { if (pathname === "/oauth/callback") return null; const pending = consumePendingCallback(); if (!pending) return null; const done = auth.completePendingSignIn(pending.hash); // onMount reports it, this only keeps the rejection from going unhandled done.catch(() => {}); return { handle: pending.login.handle, returnTo: pending.login.returnTo, done }; };