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"; 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 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 handleResolver: new SlingshotHandleResolver() 250 }); 251};