import 'server-only' import { Agent } from '@atproto/api' import { NodeOAuthClient, JoseKey, buildAtprotoLoopbackClientId } from '@atproto/oauth-client-node' import type { NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node' // Granular OAuth scopes — no transition:generic. // // Progressive authorization: login requests BASE_SCOPES only. The list scopes // are requested on demand via /api/oauth/upgrade when the user first touches a // list feature (share-as-list, List Editor, subscribe). Client metadata always // declares FULL_SCOPES — the spec requires each authorization request's scope // to be a subset of the metadata scope, so metadata must carry the union. // // atproto has no incremental-grant merging: each grant stands alone and the // new session replaces the old one (keyed by DID). Upgrade therefore requests // the full union, never just the delta. export const BASE_SCOPE_LIST = [ 'atproto', 'repo:app.bsky.graph.block?action=create&action=delete', 'repo:app.flockoff.blockEvent?action=create&action=update', ] export const LIST_SCOPE_LIST = [ 'repo:app.bsky.graph.list?action=create&action=delete', 'repo:app.bsky.graph.listitem?action=create', 'repo:app.bsky.graph.listblock?action=create&action=delete', ] export const FULL_SCOPE_LIST = [...BASE_SCOPE_LIST, ...LIST_SCOPE_LIST] export const BASE_SCOPES = BASE_SCOPE_LIST.join(' ') export const FULL_SCOPES = FULL_SCOPE_LIST.join(' ') // ─── Persistent Singleton Pattern ───────────────────────────────────────────── // Next.js HMR re-evaluates modules on every hot reload, resetting module-level // variables to their initial values. This clears the OAuth session store mid- // session, causing `client.restore()` to throw "session deleted by another // process" even though the user is still logged in. // // Anchoring to `globalThis` solves this: HMR recreates modules but never // recreates the global object, so stores and the client promise survive reloads. // In production (no HMR), globalThis has no practical effect. // // This is the same pattern Next.js recommends for Prisma / MongoDB clients. declare global { // eslint-disable-next-line no-var var __oauthStateStore: Map | undefined // eslint-disable-next-line no-var var __oauthSessionStore: Map | undefined // eslint-disable-next-line no-var var __oauthClientPromise: Promise | undefined } // ─── In-process token refresh lock ──────────────────────────────────────────── // Prevents concurrent token refresh races: if multiple simultaneous requests // all hit an expired token, only one refresh runs — the rest wait and reuse // the new token. Without this, parallel block job calls can trigger simultaneous // refreshes, causing the auth server to revoke all credentials as a replay attack. // // This Map-based mutex is correct for single-process deployments (Railway). // For multi-instance deployments, swap for a Redis-backed lock. const _lockMap = new Map>() // eslint-disable-next-line @typescript-eslint/no-explicit-any async function requestLock(key: string, fn: () => any): Promise { const previous = _lockMap.get(key) ?? Promise.resolve() const next: Promise = previous.then(() => Promise.resolve(fn())).finally(() => { if (_lockMap.get(key) === next) _lockMap.delete(key) }) _lockMap.set(key, next) return next } const stateStore: Map = (globalThis.__oauthStateStore ??= new Map()) const sessionStore: Map = (globalThis.__oauthSessionStore ??= new Map()) const stores = { stateStore: { get: async (key: string) => stateStore.get(key), set: async (key: string, value: NodeSavedState) => { stateStore.set(key, value) }, del: async (key: string) => { stateStore.delete(key) }, }, sessionStore: { get: async (key: string) => sessionStore.get(key), set: async (key: string, value: NodeSavedSession) => { sessionStore.set(key, value) }, del: async (key: string) => { sessionStore.delete(key) }, }, // Reliable DNS TXT record resolution for custom domain handles. // Windows system DNS can fail silently on TXT lookups; these fallbacks // ensure handles like tilth.social resolve correctly in all environments. fallbackNameservers: ['1.1.1.1', '8.8.8.8'], } export function getOAuthClient(): Promise { if (!globalThis.__oauthClientPromise) { globalThis.__oauthClientPromise = createClient() } return globalThis.__oauthClientPromise } /** * Restore an OAuth session and wrap it in an @atproto/api Agent. * * `client.restore()` returns an OAuthSession (implements FetchHandlerObject) * but NOT an Agent — it doesn't have the .com.atproto.* / .app.bsky.* * namespace proxies. Wrapping in new Agent() provides those namespaces while * the OAuthSession continues to handle token refresh transparently. */ export async function restoreAgent(did: string): Promise { const client = await getOAuthClient() const session = await client.restore(did) return new Agent(session) } /** * Restore the raw OAuthSession for token introspection. The Agent wrapper is * the wrong abstraction for reading token state — use this for scope checks. */ export async function restoreOAuthSession(did: string) { const client = await getOAuthClient() return client.restore(did) } // ─── Repo-scope semantics ───────────────────────────────────────────────────── // Granted scopes are compared by repo-scope *semantics*, not raw string // equality. This matters for legacy grants: sessions created before the scope // trim hold `repo:app.bsky.graph.listitem?action=create&action=delete`, which // is a strict superset of the now-required `?action=create` — those users must // not be re-prompted to upgrade. A granted scope satisfies a required one when // the collection matches and its action set is a superset of the required set. type RepoScope = { collection: string; actions: Set } function parseRepoScope(token: string): RepoScope | null { if (!token.startsWith('repo:')) return null const [prefix, query] = token.split('?', 2) const collection = prefix.slice('repo:'.length) const actions = new Set() if (query) { for (const pair of query.split('&')) { const [key, value] = pair.split('=', 2) if (key === 'action' && value) actions.add(value) } } return { collection, actions } } function repoScopeSatisfies(granted: RepoScope, required: RepoScope): boolean { if (granted.collection !== required.collection) return false // A repo scope with no explicit action params grants all actions if (granted.actions.size === 0) return true for (const action of required.actions) { if (!granted.actions.has(action)) return false } return true } /** * Check whether the session's granted scopes cover every list scope. * Tokens are parsed and compared per repo-scope semantics (collection match + * action superset) — never raw string equality, which would false-negative on * legacy grants with broader action sets, and never substring matching, which * could false-positive on overlapping scope names. */ export async function hasListScopes(did: string): Promise { const oauthSession = await restoreOAuthSession(did) const { scope } = await oauthSession.getTokenInfo() const grantedRepoScopes = String(scope) .split(/\s+/) .map(parseRepoScope) .filter((s): s is RepoScope => s !== null) return LIST_SCOPE_LIST.every((requiredToken) => { const required = parseRepoScope(requiredToken) if (!required) return false return grantedRepoScopes.some((granted) => repoScopeSatisfies(granted, required)) }) } function isLocalhost(url: string): boolean { return url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1') } // ─── Node.js 24 / undici v7 fetch workaround ────────────────────────────────── // Node.js 24.14.1 ships undici v7.24.4, which has a bug (fixed in v7.24.8) where // fetch(requestObject) throws "expected non-null body source" when the PDS returns // a 401 use_dpop_nonce response. What happens: // // 1. @atproto/xrpc builds: init = { body: Uint8Array, duplex: 'half', ... } // 2. OAuthSession.fetchHandler wraps it in new Request(url, init) // → Uint8Array is stored as a stream-backed ReadableStream (source = null) // 3. globalThis.fetch(request) is called. PDS responds with 401 use_dpop_nonce. // 4. undici v7.24.4 checks body.source before returning the 401 response. // source is null (stream-backed) → throws makeNetworkError('expected non-null // body source') instead of returning the 401 to dpopFetchWrapper. // 5. dpopFetchWrapper never receives the 401, so it never does the nonce retry. // // Fix: buffer the request body into a plain Uint8Array before calling // globalThis.fetch. When undici receives a Uint8Array directly (not a stream), // body.source is non-null, the 401 is correctly returned, and dpopFetchWrapper // can do the nonce retry on the second attempt. // // duplex: 'half' is intentionally omitted from the rebuilt init — it was the // trigger that caused new Request() to produce a stream-backed body in the first // place. Plain Uint8Array bodies do not require it. // // Remove this wrapper once Node.js ships an undici version >= 7.24.8. // Track at: node -e "console.log(process.versions.undici)" async function robustFetch( input: RequestInfo | URL, init?: RequestInit ): Promise { // (url, init) form — metadata GETs, token exchange, etc. Pass straight through. if (!(input instanceof Request)) { return globalThis.fetch(input, init) } // (request) form — from bindFetch inside dpopFetchWrapper. // Buffer the body so undici sees a Uint8Array (non-null source) rather than a // stream-backed ReadableStream (null source). const req = input const body = req.body ? new Uint8Array(await req.arrayBuffer()) : null return globalThis.fetch(req.url, { method: req.method, headers: req.headers, body, redirect: req.redirect, signal: req.signal, // duplex intentionally omitted — body is a buffered Uint8Array, not a stream }) } async function createClient(): Promise { const appUrl = process.env.NEXT_PUBLIC_APP_URL if (!appUrl) throw new Error('NEXT_PUBLIC_APP_URL is not set') // ── Development: loopback public client ───────────────────────────────────── // AT Protocol OAuth requires https:// (no port) for production client_ids. // For local dev, the spec provides a special "http://localhost" loopback mode // where port numbers are ignored in redirect_uri matching. if (isLocalhost(appUrl)) { const port = new URL(appUrl).port || '3000' // Port-free URI for client_id registration (port is ignored in matching) const loopbackCallbackUri = 'http://127.0.0.1/api/oauth/callback' // Port-included URI for actual redirects to reach our dev server const actualCallbackUri = `http://127.0.0.1:${port}/api/oauth/callback` const clientId = buildAtprotoLoopbackClientId({ redirect_uris: [loopbackCallbackUri], scope: FULL_SCOPES, }) console.log('[oauth] Development mode — loopback client') console.log('[oauth] client_id:', clientId) return new NodeOAuthClient({ clientMetadata: { client_id: clientId, redirect_uris: [actualCallbackUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'native', token_endpoint_auth_method: 'none', // public client dpop_bound_access_tokens: true, scope: FULL_SCOPES, }, fetch: robustFetch, requestLock, ...stores, }) } // ── Production: confidential client with ES256 key ────────────────────────── const privateKey = process.env.OAUTH_PRIVATE_KEY if (!privateKey) throw new Error('OAUTH_PRIVATE_KEY is not set') const keyset = await Promise.all([ JoseKey.fromImportable(privateKey, 'key1'), ]) return new NodeOAuthClient({ clientMetadata: { client_id: `${appUrl}/oauth-client-metadata.json`, client_name: 'FlockOff', client_uri: appUrl, redirect_uris: [`${appUrl}/api/oauth/callback`], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', dpop_bound_access_tokens: true, scope: FULL_SCOPES, jwks_uri: `${appUrl}/jwks.json`, }, keyset, fetch: robustFetch, requestLock, ...stores, }) }