import http from 'node:http'; import { mkdir, readFile, writeFile, access, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { text, isCancel } from '@clack/prompts'; import { Client, ok, type FetchHandlerObject } from '@atcute/client'; import { PasswordSession } from '@atcute/password-session'; import { OAuthClient, MemoryStore, type Store, type StoredState, scope, } from '@atcute/oauth-node-client'; import { CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver, } from '@atcute/identity-resolver'; import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; import { isActorIdentifier, type ActorIdentifier } from '@atcute/lexicons/syntax'; import type { SessionInfo } from './types.js'; import { CONFIG_DIR } from './paths.js'; import type {} from '@atcute/atproto'; const PASSWORD_SESSION_FILE = resolve(CONFIG_DIR, 'password-session.json'); const OAUTH_ACTIVE_FILE = resolve(CONFIG_DIR, 'oauth-active.json'); const OAUTH_STORE_FILE = resolve(CONFIG_DIR, 'oauth-store.json'); export async function ensureSession(): Promise { const passwordSession = await tryPasswordSession(); if (passwordSession) return passwordSession; return ensureOAuthSession(); } export async function clearOAuthSession(): Promise { await rm(OAUTH_ACTIVE_FILE, { force: true }); await rm(OAUTH_STORE_FILE, { force: true }); } async function mkdirPrivate(): Promise { await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); } async function writePrivateJson(path: string, data: unknown): Promise { await mkdirPrivate(); await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }); } async function readPrivateJson(path: string): Promise { try { await access(path); const raw = await readFile(path, 'utf8'); return JSON.parse(raw) as T; } catch { return undefined; } } // ---------- password session ---------- async function tryPasswordSession(): Promise { const { ATP_HANDLE, ATP_PASSWORD, PDS_URL } = process.env; if (!ATP_HANDLE || !ATP_PASSWORD || !PDS_URL) return; const saved = await readPrivateJson>(PASSWORD_SESSION_FILE); let auth: PasswordSession; if (saved && Object.keys(saved).length > 0) { try { auth = await PasswordSession.resume(saved as never, { onUpdate: (session) => writePrivateJson(PASSWORD_SESSION_FILE, session), onDelete: () => writePrivateJson(PASSWORD_SESSION_FILE, {}), }); } catch { auth = await loginPasswordSession(PDS_URL, ATP_HANDLE, ATP_PASSWORD); } } else { auth = await loginPasswordSession(PDS_URL, ATP_HANDLE, ATP_PASSWORD); } const rpc = new Client({ handler: auth }); const session = await ok(rpc.get('com.atproto.server.getSession')); return { did: session.did, handle: session.handle, service: PDS_URL, handler: auth }; } function loginPasswordSession(service: string, identifier: string, password: string): Promise { return PasswordSession.login( { service, identifier, password }, { onUpdate: (session) => writePrivateJson(PASSWORD_SESSION_FILE, session), onDelete: () => writePrivateJson(PASSWORD_SESSION_FILE, {}), }, ); } // ---------- oauth file store ---------- class FileStore implements Store { private cache = new Map(); private loaded = false; constructor(private path: string) {} private async load(): Promise { if (this.loaded) return; this.loaded = true; const data = await readPrivateJson>(this.path); if (!data) return; for (const [key, value] of Object.entries(data)) { this.cache.set(key, value); } } private async save(): Promise { const data: Record = {}; for (const [key, value] of this.cache) { data[key] = value; } await writePrivateJson(this.path, data); } async get(key: string): Promise { await this.load(); return this.cache.get(key); } async set(key: string, value: T): Promise { await this.load(); this.cache.set(key, value); await this.save(); } async delete(key: string): Promise { await this.load(); this.cache.delete(key); await this.save(); } async clear(): Promise { this.cache.clear(); await this.save(); } } // ---------- oauth session ---------- async function ensureOAuthSession(): Promise { const active = await readPrivateJson<{ did: string }>(OAUTH_ACTIVE_FILE); if (active?.did) { try { const oauth = createRestoreOAuthClient(); const session = await oauth.restore(active.did as `did:${string}:${string}`); const rpc = new Client({ handler: session }); const profile = await ok(rpc.get('com.atproto.server.getSession')); return { did: profile.did, handle: profile.handle, handler: session }; } catch { // fall through to fresh login } } const identifier = await text({ message: 'Enter your ATProto handle or DID', placeholder: 'you.bsky.social', validate(value) { if (!value) return 'A handle or DID is required'; if (!isActorIdentifier(value)) return 'Enter a valid handle or DID'; }, }); if (isCancel(identifier)) throw new Error('Cancelled'); const port = await getRandomPort(); const redirectUri = `http://127.0.0.1:${port}/callback`; const oauth = createOAuthClient(redirectUri); const { url } = await oauth.authorize({ target: { type: 'account', identifier: identifier as ActorIdentifier }, redirectUri, }); console.log(`\nOpen this URL in your browser to authorize:\n${url.href}\n`); const params = await runLoopbackCallbackServer(redirectUri); const { session } = await oauth.callback(params, { redirectUri }); await writePrivateJson(OAUTH_ACTIVE_FILE, { did: session.did }); const rpc = new Client({ handler: session }); const profile = await ok(rpc.get('com.atproto.server.getSession')); return { did: profile.did, handle: profile.handle, handler: session }; } function createOAuthClient(redirectUri: string): OAuthClient { return new OAuthClient({ metadata: { redirect_uris: [redirectUri], scope: [ scope.repo({ collection: ['network.cosmik.card', 'network.cosmik.connection'], action: ['create'] }), scope.rpc({ lxm: ['com.atproto.server.getSession'], aud: '*' }), ], }, actorResolver: new LocalActorResolver({ handleResolver: new CompositeHandleResolver({ methods: { dns: new NodeDnsHandleResolver(), http: new WellKnownHandleResolver(), }, }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), stores: { sessions: new FileStore(OAUTH_STORE_FILE), states: new MemoryStore({ maxSize: 10, ttl: 10 * 60_000, ttlAutopurge: true }), }, }); } function createRestoreOAuthClient(): OAuthClient { return createOAuthClient('http://127.0.0.1/callback'); } async function getRandomPort(): Promise { return new Promise((resolve, reject) => { const server = http.createServer(); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(() => reject(new Error('Could not determine callback port'))); return; } server.close(() => resolve(address.port)); }); server.on('error', reject); }); } function runLoopbackCallbackServer(redirectUri: string): Promise { const callbackUrl = new URL(redirectUri); return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', `http://${req.headers.host}`); if (url.pathname === callbackUrl.pathname) { res.writeHead(200, { 'content-type': 'text/html' }); res.end( `authenticated

authenticated!

you can close this window and return to the terminal.

`, ); server.close(() => resolve(url.searchParams)); } else { res.writeHead(404); res.end('not found'); } }); const port = Number(callbackUrl.port); server.listen(port, '127.0.0.1', () => { server.unref(); }); const timeout = setTimeout(() => { server.close(() => {}); reject(new Error('OAuth callback timed out after 5 minutes')); }, 5 * 60_000); server.on('error', (err) => { clearTimeout(timeout); reject(err); }); }); }