Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418import { 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; avatar?: string;}
export interface CurrentUser { did: Did; handle: string; avatar?: 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<void>; signIn(identifier: string, returnTo?: string): Promise<void>; addAccount(identifier: string, returnTo?: string): Promise<void>; completeSignIn(): Promise<string>; switchAccount(did: Did): Promise<void>; removeAccount(did: Did): Promise<void>; signOut(): Promise<void>; signOutAll(): Promise<void>;}
type MiniDoc = { did: Did; handle: string; pds?: string; avatar?: 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 resolveProfile = async ( identifier: string, bobbinUrl: string): Promise<AuthProfile | null> => { 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, avatar: profile.avatar }; } } 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<OAuthUserAgent | null>(null); const bobbinUrlValue = bobbinUrl; let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null); let profile = $state<AuthProfile | null>( seed ? { did: seed.did as Did, handle: seed.handle } : null ); let error = $state<string | null>(null); let authenticating = $state(false); let accounts = $state<AuthAccount[]>([]);
// 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, avatar: profile.avatar, 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<boolean> => { 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, avatar: profile?.avatar }; }, get accounts() { return accounts; }, refresh, signIn, addAccount: signIn, completeSignIn, switchAccount, removeAccount, signOut, signOutAll };};
export const getAuth = () => getContext<Auth>(AUTH_KEY);