Barazo AppView backend
barazo.forum
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}