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<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 auth
let 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 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 };
};