Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546import { browser } from "$app/environment";import { CompositeDidDocumentResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver} from "@atcute/identity-resolver";import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax";import { OAuthResponseError, OAuthUserAgent, TokenRefreshError, configureOAuth, createAuthorizationUrl, deleteStoredSession, finalizeAuthorization, getSession, listStoredSessions} from "@atcute/oauth-browser-client";import { getContext } from "svelte";import { SvelteMap, 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";import { writePendingLogin } from "./auth/pending";
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; profile: AuthProfile | null } | { 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 resolving: boolean; readonly currentUser: CurrentUser | null; readonly accounts: AuthAccount[]; hasAccount(did: string): boolean; bobbinUrl: string; refresh(): Promise<void>; signIn(identifier: string, returnTo?: string): Promise<void>; addAccount(identifier: string, returnTo?: string): Promise<void>; completeSignIn(): Promise<string>; completePendingSignIn(hash: string): Promise<void>; switchAccount(did: Did, returnTo?: string): Promise<boolean>; agentFor(did: Did): Promise<OAuthUserAgent>; removeAccount(did: Did): Promise<void>; signOut(): Promise<void>; signOutAll(): Promise<void>;}
type MiniDoc = { did: Did; handle: string; pds?: string;};
type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[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 isDeadSessionError = (cause: unknown): boolean => cause instanceof TokenRefreshError || (cause instanceof OAuthResponseError && cause.status === 400 && (cause.error === "invalid_grant" || cause.error === "invalid_token"));
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: 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<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()); 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 ?? 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); persistActive(did, nextProfile.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); void hydrateProfile(did, nextAgent); };
const forgetSession = (did: Did) => { otherAgents.delete(did); deleteStoredSession(did); saveAccounts(dropAccount(loadAccounts(), 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): Promise<OAuthUserAgent> => { configure(); // a sign-in exchange may still be storing this session if (pendingExchange) await pendingExchange.catch(() => {}); const active = currentAgent(); const cached = 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) { 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: 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 { // 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(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; } };
// 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); } 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); saveAccounts(dropAccount(loadAccounts(), did)); accounts = reconcileAccounts(listStoredSessions(), loadAccounts());
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())); otherAgents.clear(); 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 resolving() { return ( state.kind === "authenticating" || (state.kind === "profile-loading" && state.profile === null) || (state.kind === "loading" && state.did === 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, signIn, addAccount: signIn, completeSignIn, completePendingSignIn, switchAccount, removeAccount, signOut, signOutAll };};
export const getAuth = () => getContext<Auth>(AUTH_KEY);