import { NodeOAuthClient } from "@atproto/oauth-client-node"; import type { NodeSavedSession, NodeSavedState, } from "@atproto/oauth-client-node"; import { JoseKey } from "@atproto/jwk-jose"; import { Agent } from "@atproto/api"; import { Database } from "bun:sqlite"; import * as fs from "node:fs"; import * as path from "node:path"; // Constants const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:8000"; const DATA_DIR = process.env.DATA_DIR || "./data"; const DB_PATH = path.join(DATA_DIR, "oauth.db"); const KEYS_PATH = path.join(DATA_DIR, "private-key.json"); // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Initialize SQLite database const db = new Database(DB_PATH); // Create tables for OAuth state and sessions db.run(` CREATE TABLE IF NOT EXISTS oauth_states ( key TEXT PRIMARY KEY, state TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) ) `); db.run(` CREATE TABLE IF NOT EXISTS oauth_sessions ( did TEXT PRIMARY KEY, session TEXT NOT NULL, updated_at INTEGER DEFAULT (strftime('%s', 'now')) ) `); // Clean up old states (older than 1 hour) db.run( `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, ); // State store implementation const stateStore = { async set(key: string, state: NodeSavedState): Promise { const stateJson = JSON.stringify(state); db.run( `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, [key, stateJson], ); }, async get(key: string): Promise { const row = db .query(`SELECT state FROM oauth_states WHERE key = ?`) .get(key) as { state: string } | null; if (!row) return undefined; return JSON.parse(row.state); }, async del(key: string): Promise { db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]); }, }; // Session store implementation const sessionStore = { async set(did: string, session: NodeSavedSession): Promise { const sessionJson = JSON.stringify(session); db.run( `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, [did, sessionJson], ); }, async get(did: string): Promise { const row = db .query(`SELECT session FROM oauth_sessions WHERE did = ?`) .get(did) as { session: string } | null; if (!row) return undefined; return JSON.parse(row.session); }, async del(did: string): Promise { db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]); }, }; // Generate or load private key for confidential client async function getOrCreatePrivateKey(): Promise { if (fs.existsSync(KEYS_PATH)) { const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8")); return JoseKey.fromJWK(keyData, keyData.kid); } // Generate a new ES256 key const key = await JoseKey.generate(["ES256"], crypto.randomUUID()); const jwk = key.privateJwk; // Save to disk with restrictive permissions (owner read/write only) fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 }); return key; } let oauthClientInstance: NodeOAuthClient | null = null; let initPromise: Promise | null = null; async function initOAuthClient(): Promise { if (oauthClientInstance) return oauthClientInstance; if (initPromise) return initPromise; initPromise = (async () => { const privateKey = await getOrCreatePrivateKey(); oauthClientInstance = new NodeOAuthClient({ clientMetadata: { client_id: `${PUBLIC_URL}/client-metadata.json`, client_name: "sitebase", client_uri: PUBLIC_URL, redirect_uris: [`${PUBLIC_URL}/auth/callback`], scope: "atproto transition:generic", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "web", token_endpoint_auth_method: "private_key_jwt", token_endpoint_auth_signing_alg: "ES256", dpop_bound_access_tokens: true, jwks_uri: `${PUBLIC_URL}/jwks.json`, }, keyset: [privateKey], stateStore, sessionStore, }); return oauthClientInstance; })(); return initPromise; } export async function getOAuthClient(): Promise { return initOAuthClient(); } export async function getClientMetadata() { const client = await getOAuthClient(); return client.clientMetadata; } export async function getJwks() { const client = await getOAuthClient(); return client.jwks; } export async function getAgentForSession( did: string, ): Promise<{ agent: Agent; did: string; handle: string }> { const client = await getOAuthClient(); const oauthSession = await client.restore(did); if (!oauthSession) { throw new Error("Session not found"); } const agent = new Agent(oauthSession); // Fetch profile to get handle const profile = await agent.getProfile({ actor: did }); return { agent, did, handle: profile.data.handle, }; } export async function deleteSession(did: string): Promise { await sessionStore.del(did); }