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