Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 9.3 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"; 5import { SlingshotHandleResolver } from "./slingshot-handle-resolver"; 6 7// OAuth scope for all client types 8const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/*'; 9// Session timeout configuration (30 days in seconds) 10const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 11// OAuth state timeout (1 hour in seconds) 12const STATE_TIMEOUT = 60 * 60; // 3600 seconds 13 14const stateStore = { 15 async set(key: string, data: any) { 16 console.debug('[stateStore] set', key) 17 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 18 await db` 19 INSERT INTO oauth_states (key, data, created_at, expires_at) 20 VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 21 ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 22 `; 23 }, 24 async get(key: string) { 25 console.debug('[stateStore] get', key) 26 const now = Math.floor(Date.now() / 1000); 27 const result = await db` 28 SELECT data, expires_at 29 FROM oauth_states 30 WHERE key = ${key} 31 `; 32 if (!result[0]) return undefined; 33 34 // Check if expired 35 const expiresAt = Number(result[0].expires_at); 36 if (expiresAt && now > expiresAt) { 37 console.debug('[stateStore] State expired, deleting', key); 38 await db`DELETE FROM oauth_states WHERE key = ${key}`; 39 return undefined; 40 } 41 42 return JSON.parse(result[0].data); 43 }, 44 async del(key: string) { 45 console.debug('[stateStore] del', key) 46 await db`DELETE FROM oauth_states WHERE key = ${key}`; 47 } 48}; 49 50const sessionStore = { 51 async set(sub: string, data: any) { 52 console.debug('[sessionStore] set', sub) 53 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 54 await db` 55 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 56 VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 57 ON CONFLICT (sub) DO UPDATE SET 58 data = EXCLUDED.data, 59 updated_at = EXTRACT(EPOCH FROM NOW()), 60 expires_at = ${expiresAt} 61 `; 62 }, 63 async get(sub: string) { 64 const now = Math.floor(Date.now() / 1000); 65 const result = await db` 66 SELECT data, expires_at 67 FROM oauth_sessions 68 WHERE sub = ${sub} 69 `; 70 if (!result[0]) return undefined; 71 72 // Check if expired 73 const expiresAt = Number(result[0].expires_at); 74 if (expiresAt && now > expiresAt) { 75 logger.debug('[sessionStore] Session expired, deleting', { sub }); 76 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 77 return undefined; 78 } 79 80 return JSON.parse(result[0].data); 81 }, 82 async del(sub: string) { 83 console.debug('[sessionStore] del', sub) 84 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 85 } 86}; 87 88export { sessionStore }; 89 90// Cleanup expired sessions and states 91export const cleanupExpiredSessions = async () => { 92 const now = Math.floor(Date.now() / 1000); 93 try { 94 const sessionsDeleted = await db` 95 DELETE FROM oauth_sessions WHERE expires_at < ${now} 96 `; 97 const statesDeleted = await db` 98 DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 99 `; 100 logger.info(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 101 return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 102 } catch (err) { 103 logger.error('[Cleanup] Failed to cleanup expired data', err); 104 return { sessions: 0, states: 0 }; 105 } 106}; 107 108export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => { 109 const isLocalDev = Bun.env.LOCAL_DEV === 'true'; 110 111 if (isLocalDev) { 112 // Loopback client for local development 113 // For loopback, scopes and redirect_uri must be in client_id query string 114 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 115 const params = new URLSearchParams(); 116 params.append('redirect_uri', redirectUri); 117 params.append('scope', OAUTH_SCOPE); 118 119 return { 120 client_id: `http://localhost?${params.toString()}`, 121 client_name: config.clientName, 122 client_uri: `https://wisp.place`, 123 redirect_uris: [redirectUri], 124 grant_types: ['authorization_code', 'refresh_token'], 125 response_types: ['code'], 126 application_type: 'web', 127 token_endpoint_auth_method: 'none', 128 scope: OAUTH_SCOPE, 129 dpop_bound_access_tokens: false, 130 subject_type: 'public', 131 authorization_signed_response_alg: 'ES256' 132 } as ClientMetadata; 133 } 134 135 // Production client with private_key_jwt 136 return { 137 client_id: `${config.domain}/client-metadata.json`, 138 client_name: config.clientName, 139 client_uri: `https://wisp.place`, 140 logo_uri: `${config.domain}/logo.png`, 141 tos_uri: `${config.domain}/tos`, 142 policy_uri: `${config.domain}/policy`, 143 redirect_uris: [`${config.domain}/api/auth/callback`], 144 grant_types: ['authorization_code', 'refresh_token'], 145 response_types: ['code'], 146 application_type: 'web', 147 token_endpoint_auth_method: 'private_key_jwt', 148 token_endpoint_auth_signing_alg: "ES256", 149 scope: OAUTH_SCOPE, 150 dpop_bound_access_tokens: true, 151 jwks_uri: `${config.domain}/jwks.json`, 152 subject_type: 'public', 153 authorization_signed_response_alg: 'ES256' 154 } as ClientMetadata; 155}; 156 157const persistKey = async (key: JoseKey) => { 158 const priv = key.privateJwk; 159 if (!priv) return; 160 const kid = key.kid ?? crypto.randomUUID(); 161 await db` 162 INSERT INTO oauth_keys (kid, jwk, created_at) 163 VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 164 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 165 `; 166}; 167 168const loadPersistedKeys = async (): Promise<JoseKey[]> => { 169 const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 170 const keys: JoseKey[] = []; 171 for (const row of rows) { 172 try { 173 const obj = JSON.parse(row.jwk); 174 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 175 keys.push(key); 176 } catch (err) { 177 logger.error('[OAuth] Could not parse stored JWK', err); 178 } 179 } 180 return keys; 181}; 182 183const ensureKeys = async (): Promise<JoseKey[]> => { 184 let keys = await loadPersistedKeys(); 185 const needed: string[] = []; 186 for (let i = 1; i <= 3; i++) { 187 const kid = `key${i}`; 188 if (!keys.some(k => k.kid === kid)) needed.push(kid); 189 } 190 for (const kid of needed) { 191 const newKey = await JoseKey.generate(['ES256'], kid); 192 await persistKey(newKey); 193 keys.push(newKey); 194 } 195 keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 196 return keys; 197}; 198 199// Load keys from database every time (stateless - safe for horizontal scaling) 200export const getCurrentKeys = async (): Promise<JoseKey[]> => { 201 return await loadPersistedKeys(); 202}; 203 204// Key rotation - rotate keys older than 30 days (monthly rotation) 205const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 206 207export const rotateKeysIfNeeded = async (): Promise<boolean> => { 208 const now = Math.floor(Date.now() / 1000); 209 const cutoffTime = now - KEY_MAX_AGE; 210 211 try { 212 // Find keys older than 30 days 213 const oldKeys = await db` 214 SELECT kid, created_at FROM oauth_keys 215 WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 216 ORDER BY created_at ASC 217 `; 218 219 if (oldKeys.length === 0) { 220 logger.debug('[KeyRotation] No keys need rotation'); 221 return false; 222 } 223 224 logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 225 226 // Rotate the oldest key 227 const oldestKey = oldKeys[0]; 228 const oldKid = oldestKey.kid; 229 230 // Generate new key with same kid 231 const newKey = await JoseKey.generate(['ES256'], oldKid); 232 await persistKey(newKey); 233 234 logger.info(`[KeyRotation] Rotated key ${oldKid}`); 235 236 return true; 237 } catch (err) { 238 logger.error('[KeyRotation] Failed to rotate keys', err); 239 return false; 240 } 241}; 242 243export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => { 244 const keys = await ensureKeys(); 245 246 return new NodeOAuthClient({ 247 clientMetadata: createClientMetadata(config), 248 keyset: keys, 249 stateStore, 250 sessionStore, 251 handleResolver: new SlingshotHandleResolver() 252 }); 253};