Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
at main 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};