Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2import { JoseKey } from "@atproto/jwk-jose";
3import { db } from "./db";
4import { logger } from "./logger";
5import { SlingshotHandleResolver } from "./slingshot-handle-resolver";
6
7// Session timeout configuration (30 days in seconds)
8const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
9// OAuth state timeout (1 hour in seconds)
10const STATE_TIMEOUT = 60 * 60; // 3600 seconds
11
12const stateStore = {
13 async set(key: string, data: any) {
14 console.debug('[stateStore] set', key)
15 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
16 await db`
17 INSERT INTO oauth_states (key, data, created_at, expires_at)
18 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
19 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
20 `;
21 },
22 async get(key: string) {
23 console.debug('[stateStore] get', key)
24 const now = Math.floor(Date.now() / 1000);
25 const result = await db`
26 SELECT data, expires_at
27 FROM oauth_states
28 WHERE key = ${key}
29 `;
30 if (!result[0]) return undefined;
31
32 // Check if expired
33 const expiresAt = Number(result[0].expires_at);
34 if (expiresAt && now > expiresAt) {
35 console.debug('[stateStore] State expired, deleting', key);
36 await db`DELETE FROM oauth_states WHERE key = ${key}`;
37 return undefined;
38 }
39
40 return JSON.parse(result[0].data);
41 },
42 async del(key: string) {
43 console.debug('[stateStore] del', key)
44 await db`DELETE FROM oauth_states WHERE key = ${key}`;
45 }
46};
47
48const sessionStore = {
49 async set(sub: string, data: any) {
50 console.debug('[sessionStore] set', sub)
51 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
52 await db`
53 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
54 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
55 ON CONFLICT (sub) DO UPDATE SET
56 data = EXCLUDED.data,
57 updated_at = EXTRACT(EPOCH FROM NOW()),
58 expires_at = ${expiresAt}
59 `;
60 },
61 async get(sub: string) {
62 const now = Math.floor(Date.now() / 1000);
63 const result = await db`
64 SELECT data, expires_at
65 FROM oauth_sessions
66 WHERE sub = ${sub}
67 `;
68 if (!result[0]) return undefined;
69
70 // Check if expired
71 const expiresAt = Number(result[0].expires_at);
72 if (expiresAt && now > expiresAt) {
73 logger.debug('[sessionStore] Session expired, deleting', sub);
74 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
75 return undefined;
76 }
77
78 return JSON.parse(result[0].data);
79 },
80 async del(sub: string) {
81 console.debug('[sessionStore] del', sub)
82 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
83 }
84};
85
86export { sessionStore };
87
88// Cleanup expired sessions and states
89export const cleanupExpiredSessions = async () => {
90 const now = Math.floor(Date.now() / 1000);
91 try {
92 const sessionsDeleted = await db`
93 DELETE FROM oauth_sessions WHERE expires_at < ${now}
94 `;
95 const statesDeleted = await db`
96 DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
97 `;
98 logger.info(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
99 return { sessions: sessionsDeleted.length, states: statesDeleted.length };
100 } catch (err) {
101 logger.error('[Cleanup] Failed to cleanup expired data', err);
102 return { sessions: 0, states: 0 };
103 }
104};
105
106export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
107 const isLocalDev = Bun.env.LOCAL_DEV === 'true';
108
109 if (isLocalDev) {
110 // Loopback client for local development
111 // For loopback, scopes and redirect_uri must be in client_id query string
112 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
113 const scope = 'atproto transition:generic';
114 const params = new URLSearchParams();
115 params.append('redirect_uri', redirectUri);
116 params.append('scope', scope);
117
118 return {
119 client_id: `http://localhost?${params.toString()}`,
120 client_name: config.clientName,
121 client_uri: `https://wisp.place`,
122 redirect_uris: [redirectUri],
123 grant_types: ['authorization_code', 'refresh_token'],
124 response_types: ['code'],
125 application_type: 'web',
126 token_endpoint_auth_method: 'none',
127 scope: scope,
128 dpop_bound_access_tokens: false,
129 subject_type: 'public'
130 };
131 }
132
133 // Production client with private_key_jwt
134 return {
135 client_id: `${config.domain}/client-metadata.json`,
136 client_name: config.clientName,
137 client_uri: `https://wisp.place`,
138 logo_uri: `${config.domain}/logo.png`,
139 tos_uri: `${config.domain}/tos`,
140 policy_uri: `${config.domain}/policy`,
141 redirect_uris: [`${config.domain}/api/auth/callback`],
142 grant_types: ['authorization_code', 'refresh_token'],
143 response_types: ['code'],
144 application_type: 'web',
145 token_endpoint_auth_method: 'private_key_jwt',
146 token_endpoint_auth_signing_alg: "ES256",
147 scope: "atproto transition:generic",
148 dpop_bound_access_tokens: true,
149 jwks_uri: `${config.domain}/jwks.json`,
150 subject_type: 'public',
151 authorization_signed_response_alg: 'ES256'
152 };
153};
154
155const persistKey = async (key: JoseKey) => {
156 const priv = key.privateJwk;
157 if (!priv) return;
158 const kid = key.kid ?? crypto.randomUUID();
159 await db`
160 INSERT INTO oauth_keys (kid, jwk, created_at)
161 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
162 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
163 `;
164};
165
166const loadPersistedKeys = async (): Promise<JoseKey[]> => {
167 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
168 const keys: JoseKey[] = [];
169 for (const row of rows) {
170 try {
171 const obj = JSON.parse(row.jwk);
172 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
173 keys.push(key);
174 } catch (err) {
175 logger.error('[OAuth] Could not parse stored JWK', err);
176 }
177 }
178 return keys;
179};
180
181const ensureKeys = async (): Promise<JoseKey[]> => {
182 let keys = await loadPersistedKeys();
183 const needed: string[] = [];
184 for (let i = 1; i <= 3; i++) {
185 const kid = `key${i}`;
186 if (!keys.some(k => k.kid === kid)) needed.push(kid);
187 }
188 for (const kid of needed) {
189 const newKey = await JoseKey.generate(['ES256'], kid);
190 await persistKey(newKey);
191 keys.push(newKey);
192 }
193 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
194 return keys;
195};
196
197// Load keys from database every time (stateless - safe for horizontal scaling)
198export const getCurrentKeys = async (): Promise<JoseKey[]> => {
199 return await loadPersistedKeys();
200};
201
202// Key rotation - rotate keys older than 30 days (monthly rotation)
203const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
204
205export const rotateKeysIfNeeded = async (): Promise<boolean> => {
206 const now = Math.floor(Date.now() / 1000);
207 const cutoffTime = now - KEY_MAX_AGE;
208
209 try {
210 // Find keys older than 30 days
211 const oldKeys = await db`
212 SELECT kid, created_at FROM oauth_keys
213 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
214 ORDER BY created_at ASC
215 `;
216
217 if (oldKeys.length === 0) {
218 logger.debug('[KeyRotation] No keys need rotation');
219 return false;
220 }
221
222 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
223
224 // Rotate the oldest key
225 const oldestKey = oldKeys[0];
226 const oldKid = oldestKey.kid;
227
228 // Generate new key with same kid
229 const newKey = await JoseKey.generate(['ES256'], oldKid);
230 await persistKey(newKey);
231
232 logger.info(`[KeyRotation] Rotated key ${oldKid}`);
233
234 return true;
235 } catch (err) {
236 logger.error('[KeyRotation] Failed to rotate keys', err);
237 return false;
238 }
239};
240
241export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
242 const keys = await ensureKeys();
243
244 return new NodeOAuthClient({
245 clientMetadata: createClientMetadata(config),
246 keyset: keys,
247 stateStore,
248 sessionStore,
249 handleResolver: new SlingshotHandleResolver()
250 });
251};