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 repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview';
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 authorization_signed_response_alg: 'ES256'
131 } as ClientMetadata;
132 }
133
134 // Production client with private_key_jwt
135 return {
136 client_id: `${config.domain}/client-metadata.json`,
137 client_name: config.clientName,
138 client_uri: `https://wisp.place`,
139 logo_uri: `${config.domain}/logo.png`,
140 tos_uri: `${config.domain}/tos`,
141 policy_uri: `${config.domain}/policy`,
142 redirect_uris: [`${config.domain}/api/auth/callback`],
143 grant_types: ['authorization_code', 'refresh_token'],
144 response_types: ['code'],
145 application_type: 'web',
146 token_endpoint_auth_method: 'private_key_jwt',
147 token_endpoint_auth_signing_alg: "ES256",
148 scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview",
149 dpop_bound_access_tokens: true,
150 jwks_uri: `${config.domain}/jwks.json`,
151 subject_type: 'public',
152 authorization_signed_response_alg: 'ES256'
153 } as ClientMetadata;
154};
155
156const persistKey = async (key: JoseKey) => {
157 const priv = key.privateJwk;
158 if (!priv) return;
159 const kid = key.kid ?? crypto.randomUUID();
160 await db`
161 INSERT INTO oauth_keys (kid, jwk, created_at)
162 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
163 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
164 `;
165};
166
167const loadPersistedKeys = async (): Promise<JoseKey[]> => {
168 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
169 const keys: JoseKey[] = [];
170 for (const row of rows) {
171 try {
172 const obj = JSON.parse(row.jwk);
173 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
174 keys.push(key);
175 } catch (err) {
176 logger.error('[OAuth] Could not parse stored JWK', err);
177 }
178 }
179 return keys;
180};
181
182const ensureKeys = async (): Promise<JoseKey[]> => {
183 let keys = await loadPersistedKeys();
184 const needed: string[] = [];
185 for (let i = 1; i <= 3; i++) {
186 const kid = `key${i}`;
187 if (!keys.some(k => k.kid === kid)) needed.push(kid);
188 }
189 for (const kid of needed) {
190 const newKey = await JoseKey.generate(['ES256'], kid);
191 await persistKey(newKey);
192 keys.push(newKey);
193 }
194 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
195 return keys;
196};
197
198// Load keys from database every time (stateless - safe for horizontal scaling)
199export const getCurrentKeys = async (): Promise<JoseKey[]> => {
200 return await loadPersistedKeys();
201};
202
203// Key rotation - rotate keys older than 30 days (monthly rotation)
204const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
205
206export const rotateKeysIfNeeded = async (): Promise<boolean> => {
207 const now = Math.floor(Date.now() / 1000);
208 const cutoffTime = now - KEY_MAX_AGE;
209
210 try {
211 // Find keys older than 30 days
212 const oldKeys = await db`
213 SELECT kid, created_at FROM oauth_keys
214 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
215 ORDER BY created_at ASC
216 `;
217
218 if (oldKeys.length === 0) {
219 logger.debug('[KeyRotation] No keys need rotation');
220 return false;
221 }
222
223 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
224
225 // Rotate the oldest key
226 const oldestKey = oldKeys[0];
227 const oldKid = oldestKey.kid;
228
229 // Generate new key with same kid
230 const newKey = await JoseKey.generate(['ES256'], oldKid);
231 await persistKey(newKey);
232
233 logger.info(`[KeyRotation] Rotated key ${oldKid}`);
234
235 return true;
236 } catch (err) {
237 logger.error('[KeyRotation] Failed to rotate keys', err);
238 return false;
239 }
240};
241
242export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
243 const keys = await ensureKeys();
244
245 return new NodeOAuthClient({
246 clientMetadata: createClientMetadata(config),
247 keyset: keys,
248 stateStore,
249 sessionStore,
250 handleResolver: new SlingshotHandleResolver()
251 });
252};