A minimal web editor for managing standard.site records in your atproto PDS
at main 5.3 kB view raw
1import { NodeOAuthClient } from "@atproto/oauth-client-node"; 2import type { 3 NodeSavedSession, 4 NodeSavedState, 5} from "@atproto/oauth-client-node"; 6import { JoseKey } from "@atproto/jwk-jose"; 7import { Agent } from "@atproto/api"; 8import { Database } from "bun:sqlite"; 9import * as fs from "fs"; 10import * as path from "path"; 11 12// Constants 13const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:8000"; 14const DATA_DIR = process.env.DATA_DIR || "./data"; 15const DB_PATH = path.join(DATA_DIR, "oauth.db"); 16const KEYS_PATH = path.join(DATA_DIR, "private-key.json"); 17 18// Ensure data directory exists 19if (!fs.existsSync(DATA_DIR)) { 20 fs.mkdirSync(DATA_DIR, { recursive: true }); 21} 22 23// Initialize SQLite database 24const db = new Database(DB_PATH); 25 26// Create tables for OAuth state and sessions 27db.run(` 28 CREATE TABLE IF NOT EXISTS oauth_states ( 29 key TEXT PRIMARY KEY, 30 state TEXT NOT NULL, 31 created_at INTEGER DEFAULT (strftime('%s', 'now')) 32 ) 33`); 34 35db.run(` 36 CREATE TABLE IF NOT EXISTS oauth_sessions ( 37 did TEXT PRIMARY KEY, 38 session TEXT NOT NULL, 39 updated_at INTEGER DEFAULT (strftime('%s', 'now')) 40 ) 41`); 42 43// Clean up old states (older than 1 hour) 44db.run( 45 `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 46); 47 48// State store implementation 49const stateStore = { 50 async set(key: string, state: NodeSavedState): Promise<void> { 51 const stateJson = JSON.stringify(state); 52 db.run( 53 `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, 54 [key, stateJson], 55 ); 56 }, 57 async get(key: string): Promise<NodeSavedState | undefined> { 58 const row = db 59 .query(`SELECT state FROM oauth_states WHERE key = ?`) 60 .get(key) as { state: string } | null; 61 if (!row) return undefined; 62 return JSON.parse(row.state); 63 }, 64 async del(key: string): Promise<void> { 65 db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]); 66 }, 67}; 68 69// Session store implementation 70const sessionStore = { 71 async set(did: string, session: NodeSavedSession): Promise<void> { 72 const sessionJson = JSON.stringify(session); 73 db.run( 74 `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, 75 [did, sessionJson], 76 ); 77 }, 78 async get(did: string): Promise<NodeSavedSession | undefined> { 79 const row = db 80 .query(`SELECT session FROM oauth_sessions WHERE did = ?`) 81 .get(did) as { session: string } | null; 82 if (!row) return undefined; 83 return JSON.parse(row.session); 84 }, 85 async del(did: string): Promise<void> { 86 db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]); 87 }, 88}; 89 90// Generate or load private key for confidential client 91async function getOrCreatePrivateKey(): Promise<JoseKey> { 92 if (fs.existsSync(KEYS_PATH)) { 93 const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8")); 94 return JoseKey.fromJWK(keyData, keyData.kid); 95 } 96 97 // Generate a new ES256 key 98 const key = await JoseKey.generate(["ES256"], crypto.randomUUID()); 99 const jwk = key.privateJwk; 100 101 // Save to disk with restrictive permissions (owner read/write only) 102 fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 }); 103 104 return key; 105} 106 107let oauthClientInstance: NodeOAuthClient | null = null; 108let initPromise: Promise<NodeOAuthClient> | null = null; 109 110async function initOAuthClient(): Promise<NodeOAuthClient> { 111 if (oauthClientInstance) return oauthClientInstance; 112 if (initPromise) return initPromise; 113 114 initPromise = (async () => { 115 const privateKey = await getOrCreatePrivateKey(); 116 117 oauthClientInstance = new NodeOAuthClient({ 118 clientMetadata: { 119 client_id: `${PUBLIC_URL}/client-metadata.json`, 120 client_name: "std.pub", 121 client_uri: PUBLIC_URL, 122 redirect_uris: [`${PUBLIC_URL}/auth/callback`], 123 scope: "atproto transition:generic", 124 grant_types: ["authorization_code", "refresh_token"], 125 response_types: ["code"], 126 application_type: "web", 127 token_endpoint_auth_method: "private_key_jwt", 128 token_endpoint_auth_signing_alg: "ES256", 129 dpop_bound_access_tokens: true, 130 jwks_uri: `${PUBLIC_URL}/jwks.json`, 131 }, 132 keyset: [privateKey], 133 stateStore, 134 sessionStore, 135 }); 136 137 return oauthClientInstance; 138 })(); 139 140 return initPromise; 141} 142 143export async function getOAuthClient(): Promise<NodeOAuthClient> { 144 return initOAuthClient(); 145} 146 147export async function getClientMetadata() { 148 const client = await getOAuthClient(); 149 return client.clientMetadata; 150} 151 152export async function getJwks() { 153 const client = await getOAuthClient(); 154 return client.jwks; 155} 156 157export async function getAgentForSession( 158 did: string, 159): Promise<{ agent: Agent; did: string; handle: string }> { 160 const client = await getOAuthClient(); 161 const oauthSession = await client.restore(did); 162 163 if (!oauthSession) { 164 throw new Error("Session not found"); 165 } 166 167 const agent = new Agent(oauthSession); 168 169 // Fetch profile to get handle 170 const profile = await agent.getProfile({ actor: did }); 171 172 return { 173 agent, 174 did, 175 handle: profile.data.handle, 176 }; 177} 178 179export async function deleteSession(did: string): Promise<void> { 180 await sessionStore.del(did); 181}