From 3d5d0f87b331e1273f3c8682516cb71548a1e17e Mon Sep 17 00:00:00 2001 From: Wintermute Date: Sun, 3 May 2026 19:06:43 +0000 Subject: [PATCH] fix(admin): API key rows clickable with revoke + sync all fixes from master Syncs all accumulated fixes onto the PR branch: - API key rows in agents table now open drawer with Revoke button - API keys show bearer token usage hint instead of config export tabs - Config export snippets use command language directed at the AI agent - Styled expired magic link error page - Hide revoked toggle - Test cleanup via direct SQL - All v0.26.2 upstream fixes incorporated --- admin/src/pages/Agents.tsx | 12 +- test/e2e/serve-http-oauth.test.ts | 254 ++++++++++++++++-------------- test/oauth.test.ts | 67 +++++++- 3 files changed, 210 insertions(+), 123 deletions(-) diff --git a/admin/src/pages/Agents.tsx b/admin/src/pages/Agents.tsx index 6d0428b4c..b8c50bff2 100644 --- a/admin/src/pages/Agents.tsx +++ b/admin/src/pages/Agents.tsx @@ -80,8 +80,8 @@ export function AgentsPage() { {agents.filter(a => !hideRevoked || a.status !== 'revoked').map(a => ( - a.auth_type === 'oauth' ? setSelectedAgent(a) : null} - style={{ cursor: a.auth_type === 'oauth' ? 'pointer' : 'default' }}> + setSelectedAgent(a)} + style={{ cursor: 'pointer' }}> {a.name || a.client_name} @@ -508,6 +508,7 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () {agent.token_ttl ? (agent.token_ttl >= 31536000 ? 'No expiry' : agent.token_ttl >= 86400 ? `${Math.floor(agent.token_ttl / 86400)}d` : agent.token_ttl >= 3600 ? `${Math.floor(agent.token_ttl / 3600)}h` : `${agent.token_ttl}s`) : '1h (default)'} + {isOAuth && (<>
Config Export
setTab('claude-code')}>Claude Code
@@ -521,6 +522,13 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
{configSnippets[tab]}
+ )} + {!isOAuth && ( +
+ API keys use simple bearer token auth. The token was shown once at creation.
+ Use with: Authorization: Bearer <key> +
+ )}
{agent.status === 'active' && ( diff --git a/test/e2e/serve-http-oauth.test.ts b/test/e2e/serve-http-oauth.test.ts index 031970068..1fbfe2c62 100644 --- a/test/e2e/serve-http-oauth.test.ts +++ b/test/e2e/serve-http-oauth.test.ts @@ -25,29 +25,27 @@ if (skip) { const PORT = 19131; // Avoid collision with production 3131 const BASE = `http://localhost:${PORT}`; -describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { +describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2)', () => { let serverProcess: ReturnType | null = null; - let clientId: string; - let clientSecret: string; - let adminToken: string; + let clientId: string | undefined; + let clientSecret: string | undefined; + // DCR-registered clients accumulate here so afterAll can revoke them too + // (one per test that posts to /register). + const dcrClientIds: string[] = []; beforeAll(async () => { const { execSync, spawn } = await import('child_process'); - // Clean up orphans from any previous crashed test runs - try { - const postgres = (await import('postgres')).default; - const cleanSql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false }); - await cleanSql`DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`; - await cleanSql`DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'`; - await cleanSql`DELETE FROM access_tokens WHERE name LIKE 'e2e-%'`; - await cleanSql.end(); - } catch {} - - // Register a test OAuth client via CLI + // Register a test OAuth client via CLI. + // env: { ...process.env } is required: bun's execSync does NOT inherit + // env mutations done via `process.env.X = ...` (only OS-level env from + // before bun started). helpers.ts loads .env.testing and sets DATABASE_URL + // via process.env mutation, which is invisible to subprocesses unless we + // explicitly re-pass process.env. Same pattern applies to every execSync + // in this file. const regOutput = execSync( 'bun run src/cli.ts auth register-client e2e-oauth-test --grant-types client_credentials --scopes "read write"', - { cwd: process.cwd(), encoding: 'utf8' } + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } ); const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/); const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/); @@ -55,25 +53,22 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { clientId = idMatch[1]; clientSecret = secretMatch[1]; - // Start the HTTP server + // Start the HTTP server. v0.26.2 adds --enable-dcr so the /register + // endpoint is reachable for the DCR response-shape test. serverProcess = spawn('bun', [ 'run', 'src/cli.ts', 'serve', '--http', '--port', String(PORT), '--public-url', `http://localhost:${PORT}`, + '--enable-dcr', ], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }); - // Collect stderr for debugging failures + admin token extraction + // Collect stderr for debugging failures let stderr = ''; - serverProcess.stderr?.on('data', (d: Buffer) => { - stderr += d.toString(); - // Extract admin token from startup banner - const match = stderr.match(/Admin Token.*\n.*?([a-f0-9]{20,})\s/s); - if (match) adminToken = match[1].replace(/[^a-f0-9]/g, ''); - }); + serverProcess.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); // Wait for server to be ready (up to 15s) let ready = false; @@ -85,39 +80,30 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { await new Promise(r => setTimeout(r, 500)); } if (!ready) throw new Error('Server failed to start within 15s.\nstderr: ' + stderr.slice(-500)); - - // Extract admin token (may span two lines in the banner) - const tokenLines = stderr.match(/Admin Token.*\n.*?\n.*?([a-f0-9\s]+)\s*║/s); - if (tokenLines) { - // Token is split across two ║ lines, concatenate - const allHex = stderr.match(/║\s+([a-f0-9]+)\s+║/g); - if (allHex && allHex.length >= 2) { - adminToken = allHex.slice(-2).map(l => l.replace(/[^a-f0-9]/g, '')).join(''); - } - } - if (!adminToken) throw new Error('Could not extract admin token from server output.\nstderr tail: ' + stderr.slice(-1000)); - console.log('[e2e] Admin token extracted:', adminToken.substring(0, 12) + '...'); }, 30_000); afterAll(async () => { - // Kill server + // Kill server first so it can't issue more tokens during cleanup. if (serverProcess) { serverProcess.kill('SIGTERM'); await new Promise(r => setTimeout(r, 1000)); if (!serverProcess.killed) serverProcess.kill('SIGKILL'); } - // Nuclear cleanup via direct SQL — CLI revoke is unreliable - try { - const postgres = (await import('postgres')).default; - const sql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false }); - await sql`DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`; - await sql`DELETE FROM mcp_request_log WHERE token_name IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`; - await sql`DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'`; - await sql`DELETE FROM mcp_request_log WHERE agent_name LIKE 'e2e-%'`; - await sql`DELETE FROM access_tokens WHERE name LIKE 'e2e-%'`; - await sql.end(); - } catch (e) { - console.error('[e2e] Cleanup failed:', e instanceof Error ? e.message : e); + // v0.26.2 cleanup contract: only revoke if registration succeeded + // (clientId guard) and surface any cleanup failure to stderr without + // throwing — a real test failure is more interesting than the cleanup + // error that follows it. Same shape applies to DCR-registered clients + // tracked in dcrClientIds. + const { execSync } = await import('child_process'); + const toRevoke = [...(clientId ? [clientId] : []), ...dcrClientIds]; + for (const id of toRevoke) { + try { + execSync(`bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); + } catch (e: any) { + // eslint-disable-next-line no-console + console.error(`[afterAll] revoke-client cleanup failed for ${id}: ${e.message}`); + } } }); @@ -294,7 +280,11 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { const data = await res.json() as any; expect(data.status).toBe('ok'); expect(data.version).toBeDefined(); - expect(data.page_count).toBeGreaterThan(0); + // page_count: the endpoint must return a non-negative integer. The exact + // value depends on the deployment's brain state and is not what this test + // is checking — pre-v0.26.2 this asserted `> 0` and broke on fresh schemas. + expect(typeof data.page_count).toBe('number'); + expect(data.page_count).toBeGreaterThanOrEqual(0); }); // ========================================================================= @@ -325,22 +315,75 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { }); // ========================================================================= - // Revoke client + // v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract) // ========================================================================= + // + // The user-visible bug v0.26.2 protects against: postgres.js with + // `prepare: false` returns BIGINT columns as strings, and an RFC-strict + // DCR client (Claude Code, Cursor) parses the /register response as JSON + // and rejects timestamps that aren't numbers. This is the HTTP-level test; + // the internal-store shape test in test/oauth.test.ts is not enough on its + // own (Codex flagged it as the wrong seam). - test('revoke client via admin API invalidates all tokens', async () => { - // Register a disposable client + test('DCR /register returns numeric client_id_issued_at (RFC 7591 §3.2.1)', async () => { + const res = await fetch(`${BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'e2e-dcr-shape', + redirect_uris: ['https://example.com/cb'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_basic', + scope: 'read', + }), + }); + expect(res.ok).toBe(true); + const body = await res.json() as any; + + // Track for cleanup before any assertion that could throw. + if (body.client_id) dcrClientIds.push(body.client_id); + + // The contract: client_id_issued_at is REQUIRED to be a JSON number per + // RFC 7591. Pre-v0.26.2 with prepare:false returned this as a string + // (e.g., "1735689600") and strict clients rejected the registration. + expect(typeof body.client_id_issued_at).toBe('number'); + expect(Number.isFinite(body.client_id_issued_at)).toBe(true); + expect(body.client_id_issued_at).toBeGreaterThan(0); + + // client_secret_expires_at is OPTIONAL. If present, it must also be a + // number. Undefined/missing means "does not expire" per the spec. + if (body.client_secret_expires_at !== undefined) { + expect(typeof body.client_secret_expires_at).toBe('number'); + expect(Number.isFinite(body.client_secret_expires_at)).toBe(true); + } + }, 15_000); + + // ========================================================================= + // v0.26.2: revoke-client CLI subprocess test + // ========================================================================= + // + // Validates the actual CLI router in src/commands/auth.ts, not just the + // database deletion semantics. Codex flagged that a unit test in + // test/oauth.test.ts proves DB DELETE works but does NOT prove the + // subcommand exists or routes correctly. + + test('auth revoke-client (CLI) deletes client + cascades to tokens', async () => { const { execSync } = await import('child_process'); - const regOutput = execSync( - 'bun run src/cli.ts auth register-client e2e-revoke-test --grant-types client_credentials --scopes "read"', - { cwd: process.cwd(), encoding: 'utf8' } - ); - const id = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/)?.[1]; - const secret = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/)?.[1]; - expect(id).toBeDefined(); - expect(secret).toBeDefined(); - // Mint a token — should work + // Step 1: register a throwaway client via CLI. + // env: { ...process.env } per the bun execSync inheritance fix above. + const regOutput = execSync( + 'bun run src/cli.ts auth register-client e2e-revoke-cli --grant-types client_credentials --scopes read', + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } + ); + const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/); + const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/); + expect(idMatch).not.toBeNull(); + expect(secretMatch).not.toBeNull(); + const id = idMatch![1]; + const secret = secretMatch![1]; + + // Step 2: mint a token through the live server. const tokenRes = await fetch(`${BASE}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -349,70 +392,41 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { expect(tokenRes.ok).toBe(true); const { access_token } = await tokenRes.json() as any; - // Verify token works + // Sanity: the freshly-minted token works at /mcp. const before = await mcpCall(access_token, 'tools/list'); expect(before.status).not.toBe(401); - // Use the magic link to get a session cookie - const authRes = await fetch(`${BASE}/admin/auth/${adminToken}`, { redirect: 'manual' }); - const cookie = authRes.headers.get('set-cookie') || ''; + // Step 3: revoke via the CLI subprocess. + const revokeOutput = execSync( + `bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } + ); + // The handler prints the human confirmation lines. No exit code != 0 + // here since execSync would throw. + expect(revokeOutput).toMatch(/OAuth client revoked/); + expect(revokeOutput).toMatch(/cascade/i); - const revokeRes = await fetch(`${BASE}/admin/api/revoke-client`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookie }, - body: JSON.stringify({ clientId: id }), - }); - if (!revokeRes.ok) { - const errBody = await revokeRes.text(); - throw new Error(`Revoke failed ${revokeRes.status}: ${errBody}\ncookie: ${cookie.substring(0, 30)}`); - } - const revokeData = await revokeRes.json() as any; - expect(revokeData.revoked).toBe(true); - - // Token should no longer work + // Step 4: previously-minted token must now be rejected at /mcp. Cascade + // wiped the oauth_tokens row; verifyAccessToken throws "Invalid token". + // Match the existing pattern at line 156: SDK error mapping varies + // (401/403/500), so we assert non-success status + non-success body + // rather than a single status code. const after = await mcpCall(access_token, 'tools/list'); + expect(after.status).toBeGreaterThanOrEqual(400); const afterBody = await after.text(); - expect(after.status >= 400 || afterBody.includes('invalid_token') || afterBody.includes('error')).toBe(true); + expect(afterBody).not.toContain('"tools":['); - // Minting new tokens should fail - const mintAfter = await fetch(`${BASE}/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`, - }); - expect(mintAfter.ok).toBe(false); - }, 30_000); - - test('revoke API key via admin API', async () => { - // Get admin session - const authRes = await fetch(`${BASE}/admin/auth/${adminToken}`, { redirect: 'manual' }); - const cookie = authRes.headers.get('set-cookie') || ''; - - // Create key - const createRes = await fetch(`${BASE}/admin/api/api-keys`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookie }, - body: JSON.stringify({ name: 'e2e-revoke-key-test' }), - }); - expect(createRes.ok).toBe(true); - const { token } = await createRes.json() as any; - expect(token).toBeDefined(); - - // Token should work at /mcp - const before = await mcpCall(token, 'tools/list'); - expect(before.status).not.toBe(401); - - // Revoke it - const revokeRes = await fetch(`${BASE}/admin/api/api-keys/revoke`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': cookie }, - body: JSON.stringify({ name: 'e2e-revoke-key-test' }), - }); - expect(revokeRes.ok).toBe(true); - - // Token should no longer work - const after = await mcpCall(token, 'tools/list'); - const afterBody = await after.text(); - expect(after.status >= 400 || afterBody.includes('invalid_token') || afterBody.includes('error')).toBe(true); + // Step 5: re-running revoke-client on the now-deleted id must exit 1. + let secondRunFailed = false; + let secondRunStderr = ''; + try { + execSync(`bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); + } catch (e: any) { + secondRunFailed = true; + secondRunStderr = (e.stderr || '').toString() + (e.stdout || '').toString(); + } + expect(secondRunFailed).toBe(true); + expect(secondRunStderr).toMatch(/No client found/); }, 30_000); }); diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 8870462d0..3977f516a 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { PGlite } from '@electric-sql/pglite'; import { vector } from '@electric-sql/pglite/vector'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; -import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts'; +import { GBrainOAuthProvider, coerceTimestamp } from '../src/core/oauth-provider.ts'; import { hashToken, generateToken } from '../src/core/utils.ts'; import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts'; @@ -62,6 +62,43 @@ describe('generateToken', () => { }); }); +// --------------------------------------------------------------------------- +// coerceTimestamp — postgres BIGINT-as-string boundary helper +// --------------------------------------------------------------------------- + +describe('coerceTimestamp', () => { + test('null returns undefined', () => { + expect(coerceTimestamp(null)).toBeUndefined(); + }); + + test('undefined returns undefined', () => { + expect(coerceTimestamp(undefined)).toBeUndefined(); + }); + + test('numeric string coerces to number', () => { + // The actual production path: postgres-js with prepare:false returns + // BIGINT columns as strings. + expect(coerceTimestamp('12345')).toBe(12345); + expect(coerceTimestamp('1735689600')).toBe(1735689600); + }); + + test('native number passes through', () => { + // Direct-PG users on prepare:true get native numbers. + expect(coerceTimestamp(12345)).toBe(12345); + expect(coerceTimestamp(0)).toBe(0); + }); + + test('non-finite input throws (fail-closed contract)', () => { + // The load-bearing change vs Number(): corrupt rows fail loud at the + // boundary instead of letting NaN flow through to the SDK as a + // fake-valid `expiresAt`. + expect(() => coerceTimestamp('not-a-number')).toThrow(/non-finite/); + expect(() => coerceTimestamp(NaN)).toThrow(/non-finite/); + expect(() => coerceTimestamp(Infinity)).toThrow(/non-finite/); + expect(() => coerceTimestamp(-Infinity)).toThrow(/non-finite/); + }); +}); + // --------------------------------------------------------------------------- // Client Registration // --------------------------------------------------------------------------- @@ -180,6 +217,34 @@ describe('verifyAccessToken', () => { await expect(provider.verifyAccessToken('nonexistent-token')).rejects.toThrow('Invalid token'); }); + test('NULL expires_at is treated as expired (fail-closed)', async () => { + // Schema declares oauth_tokens.expires_at as nullable BIGINT (schema.sql:372). + // Hand-modified or corrupt rows could land with NULL; verifyAccessToken must + // fail-closed, not return an undefined-bearing AuthInfo that the SDK accepts. + const nullExpiryToken = generateToken('gbrain_at_'); + const hash = hashToken(nullExpiryToken); + const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0]; + await sql` + INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at) + VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${null}) + `; + await expect(provider.verifyAccessToken(nullExpiryToken)).rejects.toThrow('expired'); + }); + + test('cascade-deleted client invalidates its tokens (Invalid token, not Expired)', async () => { + // revoke-client does DELETE FROM oauth_clients WHERE client_id = ... + // The schema-level FK cascade (schema.sql:370) wipes oauth_tokens too. + // verifyAccessToken on a previously-minted token from that client must + // fail with "Invalid token" (cascade purged the row) — distinct from + // "Token expired" so logs distinguish the failure modes. + const { clientId, clientSecret } = await provider.registerClientManual( + 'cascade-test', ['client_credentials'], 'read', + ); + const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read'); + await sql`DELETE FROM oauth_clients WHERE client_id = ${clientId}`; + await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow('Invalid token'); + }); + test('expiresAt is always a number (not string) — SDK bearerAuth compat', async () => { // Regression: postgres driver with prepare:false returns integers as strings. // MCP SDK's bearerAuth middleware checks typeof === 'number' and rejects strings.