Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704import { 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 configlet 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<void>; adoptHandle(handle: string): void; signIn(identifier: string, returnTo?: string): Promise<void>; addAccount(identifier: string, returnTo?: string): Promise<void>; startAuthorization(serviceUrl: string, returnTo?: string): Promise<string>; completeAuthorization(did: Did, response: AuthorizationResponse): Promise<void>; completeSignIn(): Promise<string>; completePendingSignIn(hash: string): Promise<void>; switchAccount(did: Did, returnTo?: string): Promise<boolean>; agentFor(did: Did, options?: AgentOptions): Promise<OAuthUserAgent>; removeAccount(did: Did): Promise<void>; signOut(): Promise<void>; signOutAll(): Promise<void>;}
export type AuthorizationResponse = Record<"code" | "state" | "iss", string>;
type MiniDoc = { did: Did; handle: string; pds?: string;};
type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[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<AuthProfile | null> => { 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<unknown> { 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<AuthState>( seed ? { kind: "loading", did: seed.did as Did, profile: { did: seed.did as Did, handle: seed.handle } } : { kind: "logged-out" } ); let accounts = $state<AuthAccount[]>([]); const otherAgents = new SvelteMap<Did, OAuthUserAgent>(); let pendingExchange: Promise<unknown> | 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<boolean> => { 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<OAuthUserAgent> => { 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<OAuthUserAgent>(() => {}); } 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<void> => { 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 authlet browserAuth: Auth | null = null;
export const getBrowserAuth = (): Auth | null => browserAuth;
export const getAuth = () => getContext<Auth>(AUTH_KEY);
export interface ResumedSignIn { handle: string; returnTo: string; done: Promise<void>;}
// 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 overexport 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 };};