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 console.debug('[sessionStore] get', sub)
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: `https://${string}`, clientName: string }): ClientMetadata => {
107 // Use editor.wisp.place for OAuth endpoints since that's where the API routes live
108 return {
109 client_id: `${config.domain}/client-metadata.json`,
110 client_name: config.clientName,
111 client_uri: `https://wisp.place`,
112 logo_uri: `${config.domain}/logo.png`,
113 tos_uri: `${config.domain}/tos`,
114 policy_uri: `${config.domain}/policy`,
115 redirect_uris: [`${config.domain}/api/auth/callback`],
116 grant_types: ['authorization_code', 'refresh_token'],
117 response_types: ['code'],
118 application_type: 'web',
119 token_endpoint_auth_method: 'private_key_jwt',
120 token_endpoint_auth_signing_alg: "ES256",
121 scope: "atproto transition:generic",
122 dpop_bound_access_tokens: true,
123 jwks_uri: `${config.domain}/jwks.json`,
124 subject_type: 'public',
125 authorization_signed_response_alg: 'ES256'
126 };
127};
128
129const persistKey = async (key: JoseKey) => {
130 const priv = key.privateJwk;
131 if (!priv) return;
132 const kid = key.kid ?? crypto.randomUUID();
133 await db`
134 INSERT INTO oauth_keys (kid, jwk, created_at)
135 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
136 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
137 `;
138};
139
140const loadPersistedKeys = async (): Promise<JoseKey[]> => {
141 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
142 const keys: JoseKey[] = [];
143 for (const row of rows) {
144 try {
145 const obj = JSON.parse(row.jwk);
146 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
147 keys.push(key);
148 } catch (err) {
149 logger.error('[OAuth] Could not parse stored JWK', err);
150 }
151 }
152 return keys;
153};
154
155const ensureKeys = async (): Promise<JoseKey[]> => {
156 let keys = await loadPersistedKeys();
157 const needed: string[] = [];
158 for (let i = 1; i <= 3; i++) {
159 const kid = `key${i}`;
160 if (!keys.some(k => k.kid === kid)) needed.push(kid);
161 }
162 for (const kid of needed) {
163 const newKey = await JoseKey.generate(['ES256'], kid);
164 await persistKey(newKey);
165 keys.push(newKey);
166 }
167 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
168 return keys;
169};
170
171let currentKeys: JoseKey[] = [];
172
173export const getCurrentKeys = () => currentKeys;
174
175// Key rotation - rotate keys older than 30 days (monthly rotation)
176const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
177
178export const rotateKeysIfNeeded = async (): Promise<boolean> => {
179 const now = Math.floor(Date.now() / 1000);
180 const cutoffTime = now - KEY_MAX_AGE;
181
182 try {
183 // Find keys older than 30 days
184 const oldKeys = await db`
185 SELECT kid, created_at FROM oauth_keys
186 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
187 ORDER BY created_at ASC
188 `;
189
190 if (oldKeys.length === 0) {
191 logger.debug('[KeyRotation] No keys need rotation');
192 return false;
193 }
194
195 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
196
197 // Rotate the oldest key
198 const oldestKey = oldKeys[0];
199 const oldKid = oldestKey.kid;
200
201 // Generate new key with same kid
202 const newKey = await JoseKey.generate(['ES256'], oldKid);
203 await persistKey(newKey);
204
205 logger.info(`[KeyRotation] Rotated key ${oldKid}`);
206
207 // Reload keys into memory
208 currentKeys = await ensureKeys();
209
210 return true;
211 } catch (err) {
212 logger.error('[KeyRotation] Failed to rotate keys', err);
213 return false;
214 }
215};
216
217export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
218 if (currentKeys.length === 0) {
219 currentKeys = await ensureKeys();
220 }
221
222 return new NodeOAuthClient({
223 clientMetadata: createClientMetadata(config),
224 keyset: currentKeys,
225 stateStore,
226 sessionStore
227 });
228};