A minimal web editor for managing standard.site records in your atproto PDS
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}