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