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 171let currentKeys: JoseKey[] = []; 172 173export const getCurrentKeys = () => currentKeys; 174 175// Key rotation - rotate keys older than 30 days (monthly rotation) 176const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 177 178export const rotateKeysIfNeeded = async (): Promise<boolean> => { 179 const now = Math.floor(Date.now() / 1000); 180 const cutoffTime = now - KEY_MAX_AGE; 181 182 try { 183 // Find keys older than 30 days 184 const oldKeys = await db` 185 SELECT kid, created_at FROM oauth_keys 186 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 187 ORDER BY created_at ASC 188 `; 189 190 if (oldKeys.length === 0) { 191 logger.debug('[KeyRotation] No keys need rotation'); 192 return false; 193 } 194 195 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 196 197 // Rotate the oldest key 198 const oldestKey = oldKeys[0]; 199 const oldKid = oldestKey.kid; 200 201 // Generate new key with same kid 202 const newKey = await JoseKey.generate(['ES256'], oldKid); 203 await persistKey(newKey); 204 205 logger.info(`[KeyRotation] Rotated key ${oldKid}`); 206 207 // Reload keys into memory 208 currentKeys = await ensureKeys(); 209 210 return true; 211 } catch (err) { 212 logger.error('[KeyRotation] Failed to rotate keys', err); 213 return false; 214 } 215}; 216 217export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 218 if (currentKeys.length === 0) { 219 currentKeys = await ensureKeys(); 220 } 221 222 return new NodeOAuthClient({ 223 clientMetadata: createClientMetadata(config), 224 keyset: currentKeys, 225 stateStore, 226 sessionStore 227 }); 228};