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