import type { ReclaimClient } from '@reclaimprotocol/client/api' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import * as oauth from 'openid-client' import { defineTool, type RegisteredTool } from '../../server.ts' import { writeSession } from './session-cache.ts' const SCOPE = 'openid profile email' const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 export function authenticateTool( client: ReclaimClient, name: 'reclaim_authenticate' | 'authenticate_builder' = 'reclaim_authenticate', ): RegisteredTool { return defineTool>( { name, description: (name === 'authenticate_builder' ? 'OLD-DEVTOOLS MODE ONLY, and narrow: it authorizes exactly two ' + 'Builder-exclusive features — allocating a remote browser ' + '(attach_browser mode="builder") and list_builder_organizations. ' + 'It does NOT authorize create_provider_version_from_capture or ' + 'get_me_providers; those need the separate old-devtools login, ' + 'reclaim_authenticate. Check both with get_devtools_mode. ' : '') + 'Authorize the CLI / MCP to make authenticated API calls. Opens ' + 'the ' + 'browser to the builder sign-in page (standard OAuth 2.0 ' + 'authorization-code + PKCE with a localhost redirect) and waits for ' + 'the user to sign in and approve. On success the access token is ' + 'cached in ~/.reclaim/config.json and activated for this session.\n\n' + 'Call with no arguments. This bearer token authenticates ' + 'dashboard/org-management calls (for example, list_orgs, or ' + 'issuing an org secret). Programmatic verification calls use a ' + 'separate org secret ' + '(rorg_…, set as RECLAIM_ORG_SECRET).', inputSchema: { type: 'object', properties: {} }, }, async() => runOAuthLoopback(client), ) } const _customFetchForOauth: oauth.CustomFetch = async( url: string, options: oauth.CustomFetchOptions, ) => { const res = await fetch(url, options as RequestInit) if( options.method === 'POST' && res.status === 200 && new URL(url).pathname.endsWith('/oauth2/register') ) { return new Response(await res.text(), { status: 201, headers: res.headers, }) } return res } /** * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252): * dynamically register a public client (RFC 7591 — Better Auth's * `oauth-provider` plugin issues no secret for it), send the user to the * browser, and catch the redirect on a loopback server. `openid-client` * handles PKCE, `state` CSRF protection, and authorization-response * validation (including a denied/failed login surfacing as * `AuthorizationResponseError`) — this function only wires those calls * together and owns the loopback server (no library spins that up for you). */ export async function runOAuthLoopback( client: ReclaimClient, // Overridable only so tests can intercept the authorize URL instead of // actually spawning a browser; every real caller uses the default. openBrowserFn: (url: string) => void = openBrowser, ): Promise { const authBase = `${client.baseUrl}/auth` const codeVerifier = oauth.randomPKCECodeVerifier() const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier) const state = oauth.randomState() // openid-client/oauth4webapi refuse any non-HTTPS issuer by default // ("only requests to HTTPS are allowed"). Better Auth's own dev default // (`canonicalOrigin()`, packages/app/src/consts.ts) is plain // `http://localhost:4001` — the server side has no HTTPS requirement at // all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise // always fail here. Loosen it ONLY for loopback hosts, matching what the // server already trusts (`authTrustedOrigins()` defaults to // localhost:4000/4001 outside production); a non-loopback http:// origin // still gets the strict check. const isLoopbackAuthBase = ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname) // Bind the callback port first: the client is registered for this exact // loopback redirect. const callback = await listenForCallback() let tokens: oauth.TokenEndpointResponse try { const config = await oauth.dynamicClientRegistration( new URL(authBase), { client_name: 'Reclaim CLI', redirect_uris: [callback.redirectUri], token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: SCOPE, }, undefined, { // Better Auth exposes its metadata at the RFC 8414 well-known path // (`.well-known/oauth-authorization-server`), not the OIDC one. algorithm: 'oauth2', // RFC 7591 §3.2.1 requires 201 Created on a successful registration; // openid-client enforces that strictly. Better Auth's // `oauth-provider` plugin doesn't conform — confirmed live against // the real server: `POST .../oauth2/register` returns 200 with an // otherwise well-formed body, which openid-client rejects outright // ("unexpected HTTP response status code") before ever looking at // it. `customFetch` set here persists onto the returned // `Configuration` for ALL its future calls too (token exchange // included, which correctly returns 200) — so the URL itself, not // just method+status, must gate the rewrite, or a genuine 200 from // the token endpoint gets corrupted into a 201 and // `authorizationCodeGrant` fails instead (confirmed by hitting // exactly that when this only checked method === 'POST'). [oauth.customFetch]: _customFetchForOauth, // Applies to the `Configuration` this call returns, so the later // `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which // reuse that same `config`) stop enforcing HTTPS too — no separate // opt-in needed for them. ...(isLoopbackAuthBase ? { execute: [oauth.allowInsecureRequests] } : {}), }, ) openBrowserFn( oauth.buildAuthorizationUrl(config, { redirect_uri: callback.redirectUri, scope: SCOPE, state, code_challenge: codeChallenge, code_challenge_method: 'S256', }).href, ) const callbackUrl = await callback.url tokens = await oauth.authorizationCodeGrant(config, callbackUrl, { pkceCodeVerifier: codeVerifier, expectedState: state, }) } catch(err) { // A denied or failed authorization surfaces as this error class with // the raw callback params on `.cause` — reword it like the previous // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description). if(err instanceof oauth.AuthorizationResponseError) { throw new Error( err.cause.get('error_description') ?? err.cause.get('error') ?? 'Sign-in failed or was cancelled.', ) } throw err } finally { callback.close() } const expiresAt = new Date( Date.now() + (tokens.expires_in ?? 3600) * 1000, ).toISOString() writeSession({ token: tokens.access_token, expiresAt }) client.setToken(tokens.access_token) return { status: 'AUTHENTICATED', note: 'Access token cached in ~/.reclaim/config.json and active now.', expiresAt, } } /** * Bind an ephemeral loopback port and start listening for the provider's * redirect. Resolves as soon as the port is known — the `url` promise settles * later, when the browser hits `/callback` — so the caller can register a * client for this exact redirect URI before sending the user off to authorize. * The ephemeral port lets any number of CLIs run concurrently. Hands back the * RAW callback URL rather than picking `code`/`state` apart itself — * `oauth.authorizationCodeGrant` does that validation. Defined below its * caller per repo convention. */ async function listenForCallback(): Promise<{ redirectUri: string url: Promise close: () => void }> { let resolveUrl: (url: URL) => void let rejectUrl: (err: Error) => void const url = new Promise((resolve, reject) => { resolveUrl = resolve rejectUrl = reject }) const server = createServer() const timer = setTimeout( () => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS, ) const port = await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', () => { const addr = server.address() if(!addr || typeof addr === 'string') { reject(new Error('Could not bind a local callback port.')) return } resolve(addr.port) }) }).catch((err) => { // Bind failed before `close()` (returned below) is ever reachable — // clear the timer ourselves, or it fires 5 minutes later and rejects // the now-unobserved `url` promise (unhandled rejection) while keeping // the process alive in the meantime. clearTimeout(timer) throw err }) // Past bind, a socket error fails the pending wait rather than throwing. server.on('error', (err) => rejectUrl(err)) const redirectUri = `http://127.0.0.1:${port}/callback` server.on('request', (req, res) => { const reqUrl = new URL(req.url ?? '/', redirectUri) if(reqUrl.pathname !== '/callback') { res.writeHead(404).end() return } res.writeHead(200, { 'content-type': 'text/html' }) res.end( 'Signed in. You can ' + 'close this tab and return to your terminal.', ) resolveUrl(reqUrl) }) return { redirectUri, url, close: () => { clearTimeout(timer) server.close() }, } } function openBrowser(url: string) { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open' const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url] try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch{ // Non-fatal: the URL is still logged for the user to open manually. } process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`) }