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 32d98407c9576542a7af10d7c7a9fb63caa13a94 252 lines 9.5 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"; 5import { SlingshotHandleResolver } from "./slingshot-handle-resolver"; 6 7// Session timeout configuration (30 days in seconds) 8const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 9// OAuth state timeout (1 hour in seconds) 10const STATE_TIMEOUT = 60 * 60; // 3600 seconds 11 12const stateStore = { 13 async set(key: string, data: any) { 14 console.debug('[stateStore] set', key) 15 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 16 await db` 17 INSERT INTO oauth_states (key, data, created_at, expires_at) 18 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 19 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 20 `; 21 }, 22 async get(key: string) { 23 console.debug('[stateStore] get', key) 24 const now = Math.floor(Date.now() / 1000); 25 const result = await db` 26 SELECT data, expires_at 27 FROM oauth_states 28 WHERE key = ${key} 29 `; 30 if (!result[0]) return undefined; 31 32 // Check if expired 33 const expiresAt = Number(result[0].expires_at); 34 if (expiresAt && now > expiresAt) { 35 console.debug('[stateStore] State expired, deleting', key); 36 await db`DELETE FROM oauth_states WHERE key = ${key}`; 37 return undefined; 38 } 39 40 return JSON.parse(result[0].data); 41 }, 42 async del(key: string) { 43 console.debug('[stateStore] del', key) 44 await db`DELETE FROM oauth_states WHERE key = ${key}`; 45 } 46}; 47 48const sessionStore = { 49 async set(sub: string, data: any) { 50 console.debug('[sessionStore] set', sub) 51 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 52 await db` 53 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 54 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 55 ON CONFLICT (sub) DO UPDATE SET 56 data = EXCLUDED.data, 57 updated_at = EXTRACT(EPOCH FROM NOW()), 58 expires_at = ${expiresAt} 59 `; 60 }, 61 async get(sub: string) { 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 repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview'; 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 authorization_signed_response_alg: 'ES256' 131 } as ClientMetadata; 132 } 133 134 // Production client with private_key_jwt 135 return { 136 client_id: `${config.domain}/client-metadata.json`, 137 client_name: config.clientName, 138 client_uri: `https://wisp.place`, 139 logo_uri: `${config.domain}/logo.png`, 140 tos_uri: `${config.domain}/tos`, 141 policy_uri: `${config.domain}/policy`, 142 redirect_uris: [`${config.domain}/api/auth/callback`], 143 grant_types: ['authorization_code', 'refresh_token'], 144 response_types: ['code'], 145 application_type: 'web', 146 token_endpoint_auth_method: 'private_key_jwt', 147 token_endpoint_auth_signing_alg: "ES256", 148 scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview", 149 dpop_bound_access_tokens: true, 150 jwks_uri: `${config.domain}/jwks.json`, 151 subject_type: 'public', 152 authorization_signed_response_alg: 'ES256' 153 } as ClientMetadata; 154}; 155 156const persistKey = async (key: JoseKey) => { 157 const priv = key.privateJwk; 158 if (!priv) return; 159 const kid = key.kid ?? crypto.randomUUID(); 160 await db` 161 INSERT INTO oauth_keys (kid, jwk, created_at) 162 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 163 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 164 `; 165}; 166 167const loadPersistedKeys = async (): Promise<JoseKey[]> => { 168 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 169 const keys: JoseKey[] = []; 170 for (const row of rows) { 171 try { 172 const obj = JSON.parse(row.jwk); 173 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 174 keys.push(key); 175 } catch (err) { 176 logger.error('[OAuth] Could not parse stored JWK', err); 177 } 178 } 179 return keys; 180}; 181 182const ensureKeys = async (): Promise<JoseKey[]> => { 183 let keys = await loadPersistedKeys(); 184 const needed: string[] = []; 185 for (let i = 1; i <= 3; i++) { 186 const kid = `key${i}`; 187 if (!keys.some(k => k.kid === kid)) needed.push(kid); 188 } 189 for (const kid of needed) { 190 const newKey = await JoseKey.generate(['ES256'], kid); 191 await persistKey(newKey); 192 keys.push(newKey); 193 } 194 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 195 return keys; 196}; 197 198// Load keys from database every time (stateless - safe for horizontal scaling) 199export const getCurrentKeys = async (): Promise<JoseKey[]> => { 200 return await loadPersistedKeys(); 201}; 202 203// Key rotation - rotate keys older than 30 days (monthly rotation) 204const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 205 206export const rotateKeysIfNeeded = async (): Promise<boolean> => { 207 const now = Math.floor(Date.now() / 1000); 208 const cutoffTime = now - KEY_MAX_AGE; 209 210 try { 211 // Find keys older than 30 days 212 const oldKeys = await db` 213 SELECT kid, created_at FROM oauth_keys 214 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 215 ORDER BY created_at ASC 216 `; 217 218 if (oldKeys.length === 0) { 219 logger.debug('[KeyRotation] No keys need rotation'); 220 return false; 221 } 222 223 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 224 225 // Rotate the oldest key 226 const oldestKey = oldKeys[0]; 227 const oldKid = oldestKey.kid; 228 229 // Generate new key with same kid 230 const newKey = await JoseKey.generate(['ES256'], oldKid); 231 await persistKey(newKey); 232 233 logger.info(`[KeyRotation] Rotated key ${oldKid}`); 234 235 return true; 236 } catch (err) { 237 logger.error('[KeyRotation] Failed to rotate keys', err); 238 return false; 239 } 240}; 241 242export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => { 243 const keys = await ensureKeys(); 244 245 return new NodeOAuthClient({ 246 clientMetadata: createClientMetadata(config), 247 keyset: keys, 248 stateStore, 249 sessionStore, 250 handleResolver: new SlingshotHandleResolver() 251 }); 252};