Barazo AppView backend barazo.forum
at main 115 lines 4.6 kB view raw
1import { NodeOAuthClient } from '@atproto/oauth-client-node' 2import type { RuntimeLock } from '@atproto/oauth-client-node' 3import type { Env } from '../config/env.js' 4import type { Cache } from '../cache/index.js' 5import type { Logger } from '../lib/logger.js' 6import { ValkeyStateStore, ValkeySessionStore } from './oauth-stores.js' 7import { BARAZO_BASE_SCOPES } from './scopes.js' 8 9const LOCK_KEY_PREFIX = 'barazo:oauth:lock:' 10const LOCK_TTL_SECONDS = 10 11const LOCK_RETRY_DELAY_MS = 1000 12 13/** 14 * Determine whether the OAuth client should operate in loopback (development) mode. 15 * Loopback mode is detected when OAUTH_CLIENT_ID starts with "http://localhost". 16 */ 17function isLoopbackMode(clientId: string): boolean { 18 return clientId.startsWith('http://localhost') 19} 20 21/** 22 * Build the client_id for loopback (development) mode. 23 * Per the AT Protocol OAuth spec, loopback clients encode their redirect_uri 24 * and scope directly in the client_id URL as query parameters. 25 */ 26function buildLoopbackClientId(redirectUri: string): string { 27 return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(BARAZO_BASE_SCOPES)}` 28} 29 30/** 31 * Create a Valkey-based distributed lock for preventing concurrent token refreshes. 32 * Uses SETNX (SET with NX flag) to ensure only one process acquires the lock. 33 * 34 * The RuntimeLock interface executes a function while holding the lock, 35 * then releases it automatically (even on error). 36 */ 37function createRequestLock(cache: Cache, logger: Logger): RuntimeLock { 38 return async <T>(name: string, fn: () => T | PromiseLike<T>): Promise<T> => { 39 const lockKey = `${LOCK_KEY_PREFIX}${name}` 40 41 // Attempt to acquire lock: SET key value EX ttl NX (only if not exists) 42 const acquired = await cache.set(lockKey, '1', 'EX', LOCK_TTL_SECONDS, 'NX') 43 if (acquired === null) { 44 // Lock not acquired, wait and retry once 45 logger.debug({ lockKey }, 'Lock not acquired, retrying') 46 await new Promise<void>((resolve) => { 47 setTimeout(resolve, LOCK_RETRY_DELAY_MS) 48 }) 49 50 const retryAcquired = await cache.set(lockKey, '1', 'EX', LOCK_TTL_SECONDS, 'NX') 51 if (retryAcquired === null) { 52 logger.warn({ lockKey }, 'Could not acquire OAuth lock after retry') 53 throw new Error(`Could not acquire OAuth lock: ${name}`) 54 } 55 } 56 57 try { 58 return await fn() 59 } finally { 60 // TODO(multi-instance): Use Redlock or check-and-delete Lua script for multi-instance safety. (#35) 61 // Current simple DEL does not verify lock ownership; safe for single-instance MVP. 62 // Only needed when SaaS tier runs multiple API instances against shared Valkey. 63 try { 64 await cache.del(lockKey) 65 } catch (err: unknown) { 66 logger.error({ err, lockKey }, 'Failed to release OAuth lock') 67 } 68 } 69 } 70} 71 72/** 73 * Create a configured NodeOAuthClient for AT Protocol authentication. 74 * 75 * Supports two modes: 76 * - **Loopback (development):** client_id is built from redirect_uri and scope params. 77 * No HTTPS needed, works with http://localhost. 78 * - **Production:** client_id points to the publicly served metadata endpoint. 79 * The PDS fetches metadata from that URL. 80 */ 81export function createOAuthClient(env: Env, cache: Cache, logger: Logger): NodeOAuthClient { 82 const loopback = isLoopbackMode(env.OAUTH_CLIENT_ID) 83 const clientId = loopback ? buildLoopbackClientId(env.OAUTH_REDIRECT_URI) : env.OAUTH_CLIENT_ID 84 85 logger.info({ loopback, clientId: loopback ? '(loopback)' : clientId }, 'Creating OAuth client') 86 87 const client = new NodeOAuthClient({ 88 clientMetadata: { 89 client_name: 'Barazo Forum', 90 client_id: clientId, 91 client_uri: loopback 92 ? 'http://localhost' 93 : env.OAUTH_CLIENT_ID.replace(/\/oauth-client-metadata\.json$/, ''), 94 redirect_uris: [env.OAUTH_REDIRECT_URI], 95 scope: BARAZO_BASE_SCOPES, 96 grant_types: ['authorization_code', 'refresh_token'], 97 response_types: ['code'], 98 application_type: 'web', 99 token_endpoint_auth_method: 'none', 100 dpop_bound_access_tokens: true, 101 }, 102 stateStore: new ValkeyStateStore(cache, logger), 103 sessionStore: new ValkeySessionStore(cache, logger, env.OAUTH_SESSION_TTL), 104 requestLock: createRequestLock(cache, logger), 105 // Session lifecycle hooks for observability (replaces addEventListener in >=0.3.17) 106 onUpdate: (sub: string) => { 107 logger.info({ sub }, 'OAuth session updated') 108 }, 109 onDelete: (sub: string, cause: unknown) => { 110 logger.info({ sub, cause: String(cause) }, 'OAuth session deleted') 111 }, 112 }) 113 114 return client 115}