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};