Something went wrong. Try again.
A CLI tool to post to Grain.social
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514import { randomBytes } from "node:crypto";import { chmod, mkdir, rm } from "node:fs/promises";import { dirname, join } from "node:path";import { Agent } from "@atproto/api";import { NodeOAuthClient, OAuthCallbackError, requestLocalLock, type NodeSavedSession, type NodeSavedSessionStore, type NodeSavedState, type NodeSavedStateStore, type OAuthSession,} from "@atproto/oauth-client-node";import { GrainError } from "./errors";import { getConfigPath, loadConfig, saveConfig } from "./config";import { buildGrainLibraryUrl } from "./links";import type { OAuthConfig } from "./types";
const LOOPBACK_ORIGIN = "http://localhost";const GRAIN_REPO_SCOPES = [ "repo:social.grain.actor.profile", "repo:social.grain.comment", "repo:social.grain.favorite", "repo:social.grain.gallery", "repo:social.grain.gallery.item", "repo:social.grain.graph.follow", "repo:social.grain.photo", "repo:social.grain.photo.exif", "repo:social.grain.story",] as const;const DEFAULT_SCOPE = ["atproto", "blob:image/*", ...GRAIN_REPO_SCOPES].join(" ");const CALLBACK_PATH = "/callback";type StoredOAuthConfig = OAuthConfig | undefined;
type OAuthStores = { stateStore: { get(key: string): Promise<NodeSavedState | undefined>; set(key: string, value: NodeSavedState): Promise<void>; del(key: string): Promise<void>; clear(): Promise<void>; all(): Promise<Record<string, NodeSavedState>>; }; sessionStore: { get(key: string): Promise<NodeSavedSession | undefined>; set(key: string, value: NodeSavedSession): Promise<void>; del(key: string): Promise<void>; clear(): Promise<void>; all(): Promise<Record<string, NodeSavedSession>>; };};
type NativeOpenCommand = "open" | "xdg-open";
function configDir(): string { return dirname(getConfigPath());}
function oauthStorePath(fileName: string): string { return join(configDir(), fileName);}
async function readJsonStore<T>(path: string): Promise<Record<string, T>> { const file = Bun.file(path); if (!(await file.exists())) { return {}; }
const raw = await file.text(); if (!raw.trim()) { return {}; }
return JSON.parse(raw) as Record<string, T>;}
async function writeJsonStore<T>(path: string, value: Record<string, T>): Promise<void> { await mkdir(dirname(path), { recursive: true }); await Bun.write(path, `${JSON.stringify(value, null, 2)}\n`); await chmod(path, 0o600);}
function createFileStore<T>(path: string): { get(key: string): Promise<T | undefined>; set(key: string, value: T): Promise<void>; del(key: string): Promise<void>; clear(): Promise<void>; all(): Promise<Record<string, T>>;} { return { async get(key) { const state = await readJsonStore<T>(path); return state[key]; }, async set(key, value) { const state = await readJsonStore<T>(path); state[key] = value; await writeJsonStore(path, state); }, async del(key) { const state = await readJsonStore<T>(path); delete state[key]; await writeJsonStore(path, state); }, async clear() { await writeJsonStore(path, {}); }, async all() { return readJsonStore<T>(path); }, };}
function makeStores(): OAuthStores { const stateStorePath = oauthStorePath("oauth-state.json"); const sessionStorePath = oauthStorePath("oauth-sessions.json");
const stateStore = createFileStore<NodeSavedState>(stateStorePath); const sessionStore = createFileStore<NodeSavedSession>(sessionStorePath);
return { stateStore, sessionStore, };}
function randomPort(): number { const min = 38000; const max = 48999; return min + (randomBytes(2).readUInt16BE(0) % (max - min));}
function getOpenCommand(): NativeOpenCommand | undefined { if (process.platform === "darwin") { return "open"; } if (process.platform === "linux") { return "xdg-open"; } return undefined;}
function buildLoopbackClientId(redirectUri: string, scope: string): string { const params = new URLSearchParams(); params.set("redirect_uri", redirectUri); params.set("scope", scope); return `${LOOPBACK_ORIGIN}?${params.toString()}`;}
function normalizeOAuthConfig(config: StoredOAuthConfig): { activeDid: string; handle: string; clientId: string; redirectUri: string; scope: string;} { if (!config) { throw new GrainError("not_logged_in", "No OAuth session found.", "Run `grain login` to authenticate."); }
return { activeDid: config.activeDid, handle: config.handle, clientId: config.clientId, redirectUri: config.redirectUri || "http://127.0.0.1/callback", scope: config.scope || DEFAULT_SCOPE, };}
async function findOAuthStateKeyByAppState(stateStore: OAuthStores["stateStore"], appState: string): Promise<string | undefined> { const entries = await stateStore.all(); for (const [key, value] of Object.entries(entries)) { if (value?.appState === appState) { return key; } } return undefined;}
function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/\"/g, """) .replace(/'/g, "'");}
function buildOAuthCallbackHtml(handle: string, ok: boolean, messageOverride?: string): string { const safeHandle = escapeHtml(handle); const heading = ok ? `🎉 You have authenticated as ${safeHandle}.` : `Authentication did not complete for ${safeHandle}.`; const message = messageOverride ?? (ok ? "You can close this page now and return to the terminal." : "You can close this page and return to the terminal for details.");
return `<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>grain login</title> <style> :root { color-scheme: light dark; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: radial-gradient(circle at top, rgba(37, 99, 235, 0.1), transparent 50%); } main { width: min(640px, calc(100vw - 3rem)); padding: 2rem; border: 1px solid rgba(120, 120, 120, 0.35); border-radius: 16px; background: rgba(255, 255, 255, 0.7); } h1 { margin: 0 0 0.75rem; font-size: 1.35rem; line-height: 1.35; } p { margin: 0; font-size: 1rem; line-height: 1.5; } </style> </head> <body> <main> <h1>${heading}</h1> <p>${message}</p> </main> </body></html>`;}
function createClient(clientId: string, redirectUri: string, scope: string, stores: OAuthStores): NodeOAuthClient { return new NodeOAuthClient({ clientMetadata: { client_id: clientId, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope, application_type: "native", token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, }, stateStore: stores.stateStore as NodeSavedStateStore, sessionStore: stores.sessionStore as NodeSavedSessionStore, requestLock: requestLocalLock, });}
async function waitForOAuthCallback( handle: string, expectedOAuthState: string, redirectPort: number, timeoutMs = 180000,): Promise<URLSearchParams> { let resolveResult!: (params: URLSearchParams) => void; let rejectResult!: (error: Error) => void;
const result = new Promise<URLSearchParams>((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
const timeout = setTimeout(() => { rejectResult(new GrainError("oauth_timeout", "OAuth login timed out.", "Run `grain login` and complete browser approval within 3 minutes.")); }, timeoutMs);
const server = Bun.serve({ hostname: "127.0.0.1", port: redirectPort, fetch(request) { try { const url = new URL(request.url); if (url.pathname !== CALLBACK_PATH) { return new Response("Not found", { status: 404 }); }
const callbackState = url.searchParams.get("state"); if (!callbackState || callbackState !== expectedOAuthState) { return new Response( buildOAuthCallbackHtml( handle, false, "This callback is from a different login attempt. You can close this tab and continue from the newest login prompt.", ), { status: 409, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }, }, ); }
const hasError = url.searchParams.has("error");
resolveResult(url.searchParams); return new Response(buildOAuthCallbackHtml(handle, !hasError), { status: hasError ? 400 : 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }, }); } catch (error) { rejectResult(new GrainError("oauth_callback_error", error instanceof Error ? error.message : String(error))); return new Response("OAuth callback failed.", { status: 500 }); } }, });
try { return await result; } finally { clearTimeout(timeout); server.stop(true); }}
export type LoginOAuthResult = { did: string; handle: string; libraryUrl: string;};
async function maybeOpenBrowser(url: string): Promise<void> { const cmd = getOpenCommand(); if (!cmd) { return; }
try { await Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" }).exited; } catch { // user can open manually }}
async function describeRepoHandle(session: OAuthSession, did: string, fallbackHandle: string): Promise<string> { try { const profile = await new Agent(session).com.atproto.repo.describeRepo({ repo: did }); return profile.data.handle || fallbackHandle; } catch { return fallbackHandle; }}
async function revokeStoredDid(config: StoredOAuthConfig): Promise<void> { if (!config) { return; }
const normalized = normalizeOAuthConfig(config); const stores = makeStores(); const client = createClient(normalized.clientId, normalized.redirectUri, normalized.scope, stores); try { await client.revoke(normalized.activeDid); } catch { // best-effort revoke }}
export async function loginWithOAuth(handle: string): Promise<LoginOAuthResult> { const previousConfig = await loadConfig(); const redirectPort = randomPort(); const redirectUri = `http://127.0.0.1:${redirectPort}${CALLBACK_PATH}`; const scope = DEFAULT_SCOPE; const clientId = buildLoopbackClientId(redirectUri, scope); const stores = makeStores(); await stores.stateStore.clear(); const oauthClient = createClient(clientId, redirectUri, scope, stores);
const appState = randomBytes(16).toString("hex"); const authorizeUrl = await oauthClient.authorize(handle, { scope, state: appState, });
const expectedOAuthState = await findOAuthStateKeyByAppState(stores.stateStore, appState); if (!expectedOAuthState) { throw new GrainError("oauth_state_missing", "OAuth login could not be initialized.", "Run `grain login` again."); }
const callbackPromise = waitForOAuthCallback(handle, expectedOAuthState, redirectPort);
console.log("Open the following URL to continue login:"); console.log(authorizeUrl.toString()); await maybeOpenBrowser(authorizeUrl.toString());
const callbackParams = await callbackPromise;
let session: OAuthSession; try { const result = await oauthClient.callback(callbackParams); if (result.state !== appState) { throw new GrainError("oauth_state_mismatch", "OAuth state mismatch. Login was rejected."); } session = result.session; } catch (error) { if (error instanceof OAuthCallbackError) { throw new GrainError("oauth_callback", error.message); } throw error; }
const did = session.did; const resolvedHandle = await describeRepoHandle(session, did, handle);
if (previousConfig?.oauth && previousConfig.oauth.activeDid !== did) { await revokeStoredDid(previousConfig.oauth); }
const config = await loadConfig(); const nextConfig = { ...(config ?? {}), oauth: { activeDid: did, handle: resolvedHandle, clientId, redirectUri, scope, loginAt: new Date().toISOString(), }, }; await saveConfig(nextConfig);
return { did, handle: resolvedHandle, libraryUrl: buildGrainLibraryUrl(did), };}
export async function getAuthorizedAgent(forceRefresh = false): Promise<{ agent: Agent; did: string; handle: string }> { const config = await loadConfig(); const oauth = normalizeOAuthConfig(config?.oauth);
const stores = makeStores(); const client = createClient(oauth.clientId, oauth.redirectUri, oauth.scope, stores); let session: OAuthSession; try { session = await client.restore(oauth.activeDid, forceRefresh ? true : "auto"); } catch (error) { throw new GrainError( "session_restore_failed", error instanceof Error ? error.message : String(error), "Run `grain login` again to refresh your OAuth session.", ); }
return { agent: new Agent(session), did: session.did, handle: oauth.handle, };}
export async function getSessionInfo(): Promise<{ expiresAt?: string } | undefined> { const config = await loadConfig(); if (!config?.oauth) { return undefined; }
const oauth = normalizeOAuthConfig(config.oauth); const stores = makeStores(); const session = await stores.sessionStore.get(oauth.activeDid); if (!session) { return undefined; }
return { expiresAt: session.tokenSet.expires_at, };}
export async function logoutOAuth(): Promise<void> { const config = await loadConfig(); if (!config?.oauth) { return; }
const oauth = normalizeOAuthConfig(config.oauth); const stores = makeStores(); const client = createClient(oauth.clientId, oauth.redirectUri, oauth.scope, stores);
try { await client.revoke(oauth.activeDid); } catch { // ignore revoke errors, still clear local state }
const nextConfig = { ...config, oauth: undefined, }; await saveConfig(nextConfig);
const statePath = oauthStorePath("oauth-state.json"); const sessionPath = oauthStorePath("oauth-sessions.json"); await rm(statePath, { force: true }); await rm(sessionPath, { force: true });}