Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
96
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ff1850f987e10d7493c0ec3ed5c7fcbbeaa0ff65 249 lines 9.0 kB view raw
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};