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