Skip to content

Commit 3d5d0f8

Browse files
author
Wintermute
committed
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
1 parent 64ec4fa commit 3d5d0f8

3 files changed

Lines changed: 209 additions & 122 deletions

File tree

admin/src/pages/Agents.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export function AgentsPage() {
8080
</thead>
8181
<tbody>
8282
{agents.filter(a => !hideRevoked || a.status !== 'revoked').map(a => (
83-
<tr key={a.id} onClick={() => a.auth_type === 'oauth' ? setSelectedAgent(a) : null}
84-
style={{ cursor: a.auth_type === 'oauth' ? 'pointer' : 'default' }}>
83+
<tr key={a.id} onClick={() => setSelectedAgent(a)}
84+
style={{ cursor: 'pointer' }}>
8585
<td style={{ fontWeight: 500 }}>{a.name || a.client_name}</td>
8686
<td>
8787
<span className={`badge ${a.auth_type === 'oauth' ? 'badge-read' : 'badge-write'}`} style={{ fontSize: 11 }}>
@@ -508,6 +508,7 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
508508
<span>{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)'}</span>
509509
</div>
510510

511+
{isOAuth && (<>
511512
<div className="section-title">Config Export</div>
512513
<div className="tabs" style={{ flexWrap: 'wrap' }}>
513514
<div className={`tab ${tab === 'claude-code' ? 'active' : ''}`} onClick={() => setTab('claude-code')}>Claude Code</div>
@@ -521,6 +522,13 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
521522
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{configSnippets[tab]}</pre>
522523
<button className="copy-btn" onClick={() => copy(configSnippets[tab])}>Copy</button>
523524
</div>
525+
</>)}
526+
{!isOAuth && (
527+
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 16 }}>
528+
API keys use simple bearer token auth. The token was shown once at creation.<br/>
529+
Use with: <code style={{ background: 'rgba(0,0,0,0.3)', padding: '2px 6px', borderRadius: 4 }}>Authorization: Bearer &lt;key&gt;</code>
530+
</div>
531+
)}
524532

525533
<div style={{ marginTop: 32 }}>
526534
{agent.status === 'active' && (

test/e2e/serve-http-oauth.test.ts

Lines changed: 133 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -25,55 +25,50 @@ if (skip) {
2525
const PORT = 19131; // Avoid collision with production 3131
2626
const BASE = `http://localhost:${PORT}`;
2727

28-
describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
28+
describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2)', () => {
2929
let serverProcess: ReturnType<typeof import('child_process').spawn> | null = null;
30-
let clientId: string;
31-
let clientSecret: string;
32-
let adminToken: string;
30+
let clientId: string | undefined;
31+
let clientSecret: string | undefined;
32+
// DCR-registered clients accumulate here so afterAll can revoke them too
33+
// (one per test that posts to /register).
34+
const dcrClientIds: string[] = [];
3335

3436
beforeAll(async () => {
3537
const { execSync, spawn } = await import('child_process');
3638

37-
// Clean up orphans from any previous crashed test runs
38-
try {
39-
const postgres = (await import('postgres')).default;
40-
const cleanSql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false });
41-
await cleanSql`DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`;
42-
await cleanSql`DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'`;
43-
await cleanSql`DELETE FROM access_tokens WHERE name LIKE 'e2e-%'`;
44-
await cleanSql.end();
45-
} catch {}
46-
47-
// Register a test OAuth client via CLI
39+
// Register a test OAuth client via CLI.
40+
// env: { ...process.env } is required: bun's execSync does NOT inherit
41+
// env mutations done via `process.env.X = ...` (only OS-level env from
42+
// before bun started). helpers.ts loads .env.testing and sets DATABASE_URL
43+
// via process.env mutation, which is invisible to subprocesses unless we
44+
// explicitly re-pass process.env. Same pattern applies to every execSync
45+
// in this file.
4846
const regOutput = execSync(
4947
'bun run src/cli.ts auth register-client e2e-oauth-test --grant-types client_credentials --scopes "read write"',
50-
{ cwd: process.cwd(), encoding: 'utf8' }
48+
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
5149
);
5250
const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/);
5351
const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/);
5452
if (!idMatch || !secretMatch) throw new Error('Failed to register test client:\n' + regOutput);
5553
clientId = idMatch[1];
5654
clientSecret = secretMatch[1];
5755

58-
// Start the HTTP server
56+
// Start the HTTP server. v0.26.2 adds --enable-dcr so the /register
57+
// endpoint is reachable for the DCR response-shape test.
5958
serverProcess = spawn('bun', [
6059
'run', 'src/cli.ts', 'serve', '--http',
6160
'--port', String(PORT),
6261
'--public-url', `http://localhost:${PORT}`,
62+
'--enable-dcr',
6363
], {
6464
cwd: process.cwd(),
6565
env: process.env,
6666
stdio: ['ignore', 'pipe', 'pipe'],
6767
});
6868

69-
// Collect stderr for debugging failures + admin token extraction
69+
// Collect stderr for debugging failures
7070
let stderr = '';
71-
serverProcess.stderr?.on('data', (d: Buffer) => {
72-
stderr += d.toString();
73-
// Extract admin token from startup banner
74-
const match = stderr.match(/Admin Token.*\n.*?([a-f0-9]{20,})\s/s);
75-
if (match) adminToken = match[1].replace(/[^a-f0-9]/g, '');
76-
});
71+
serverProcess.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
7772

7873
// Wait for server to be ready (up to 15s)
7974
let ready = false;
@@ -85,39 +80,30 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
8580
await new Promise(r => setTimeout(r, 500));
8681
}
8782
if (!ready) throw new Error('Server failed to start within 15s.\nstderr: ' + stderr.slice(-500));
88-
89-
// Extract admin token (may span two lines in the banner)
90-
const tokenLines = stderr.match(/Admin Token.*\n.*?\n.*?([a-f0-9\s]+)\s*/s);
91-
if (tokenLines) {
92-
// Token is split across two ║ lines, concatenate
93-
const allHex = stderr.match(/\s+([a-f0-9]+)\s+/g);
94-
if (allHex && allHex.length >= 2) {
95-
adminToken = allHex.slice(-2).map(l => l.replace(/[^a-f0-9]/g, '')).join('');
96-
}
97-
}
98-
if (!adminToken) throw new Error('Could not extract admin token from server output.\nstderr tail: ' + stderr.slice(-1000));
99-
console.log('[e2e] Admin token extracted:', adminToken.substring(0, 12) + '...');
10083
}, 30_000);
10184

10285
afterAll(async () => {
103-
// Kill server
86+
// Kill server first so it can't issue more tokens during cleanup.
10487
if (serverProcess) {
10588
serverProcess.kill('SIGTERM');
10689
await new Promise(r => setTimeout(r, 1000));
10790
if (!serverProcess.killed) serverProcess.kill('SIGKILL');
10891
}
109-
// Nuclear cleanup via direct SQL — CLI revoke is unreliable
110-
try {
111-
const postgres = (await import('postgres')).default;
112-
const sql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false });
113-
await sql`DELETE FROM oauth_tokens WHERE client_id IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`;
114-
await sql`DELETE FROM mcp_request_log WHERE token_name IN (SELECT client_id FROM oauth_clients WHERE client_name LIKE 'e2e-%')`;
115-
await sql`DELETE FROM oauth_clients WHERE client_name LIKE 'e2e-%'`;
116-
await sql`DELETE FROM mcp_request_log WHERE agent_name LIKE 'e2e-%'`;
117-
await sql`DELETE FROM access_tokens WHERE name LIKE 'e2e-%'`;
118-
await sql.end();
119-
} catch (e) {
120-
console.error('[e2e] Cleanup failed:', e instanceof Error ? e.message : e);
92+
// v0.26.2 cleanup contract: only revoke if registration succeeded
93+
// (clientId guard) and surface any cleanup failure to stderr without
94+
// throwing — a real test failure is more interesting than the cleanup
95+
// error that follows it. Same shape applies to DCR-registered clients
96+
// tracked in dcrClientIds.
97+
const { execSync } = await import('child_process');
98+
const toRevoke = [...(clientId ? [clientId] : []), ...dcrClientIds];
99+
for (const id of toRevoke) {
100+
try {
101+
execSync(`bun run src/cli.ts auth revoke-client "${id}"`,
102+
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } });
103+
} catch (e: any) {
104+
// eslint-disable-next-line no-console
105+
console.error(`[afterAll] revoke-client cleanup failed for ${id}: ${e.message}`);
106+
}
121107
}
122108
});
123109

@@ -294,7 +280,11 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
294280
const data = await res.json() as any;
295281
expect(data.status).toBe('ok');
296282
expect(data.version).toBeDefined();
297-
expect(data.page_count).toBeGreaterThan(0);
283+
// page_count: the endpoint must return a non-negative integer. The exact
284+
// value depends on the deployment's brain state and is not what this test
285+
// is checking — pre-v0.26.2 this asserted `> 0` and broke on fresh schemas.
286+
expect(typeof data.page_count).toBe('number');
287+
expect(data.page_count).toBeGreaterThanOrEqual(0);
298288
});
299289

300290
// =========================================================================
@@ -325,22 +315,75 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
325315
});
326316

327317
// =========================================================================
328-
// Revoke client
318+
// v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract)
319+
// =========================================================================
320+
//
321+
// The user-visible bug v0.26.2 protects against: postgres.js with
322+
// `prepare: false` returns BIGINT columns as strings, and an RFC-strict
323+
// DCR client (Claude Code, Cursor) parses the /register response as JSON
324+
// and rejects timestamps that aren't numbers. This is the HTTP-level test;
325+
// the internal-store shape test in test/oauth.test.ts is not enough on its
326+
// own (Codex flagged it as the wrong seam).
327+
328+
test('DCR /register returns numeric client_id_issued_at (RFC 7591 §3.2.1)', async () => {
329+
const res = await fetch(`${BASE}/register`, {
330+
method: 'POST',
331+
headers: { 'Content-Type': 'application/json' },
332+
body: JSON.stringify({
333+
client_name: 'e2e-dcr-shape',
334+
redirect_uris: ['https://example.com/cb'],
335+
grant_types: ['authorization_code'],
336+
token_endpoint_auth_method: 'client_secret_basic',
337+
scope: 'read',
338+
}),
339+
});
340+
expect(res.ok).toBe(true);
341+
const body = await res.json() as any;
342+
343+
// Track for cleanup before any assertion that could throw.
344+
if (body.client_id) dcrClientIds.push(body.client_id);
345+
346+
// The contract: client_id_issued_at is REQUIRED to be a JSON number per
347+
// RFC 7591. Pre-v0.26.2 with prepare:false returned this as a string
348+
// (e.g., "1735689600") and strict clients rejected the registration.
349+
expect(typeof body.client_id_issued_at).toBe('number');
350+
expect(Number.isFinite(body.client_id_issued_at)).toBe(true);
351+
expect(body.client_id_issued_at).toBeGreaterThan(0);
352+
353+
// client_secret_expires_at is OPTIONAL. If present, it must also be a
354+
// number. Undefined/missing means "does not expire" per the spec.
355+
if (body.client_secret_expires_at !== undefined) {
356+
expect(typeof body.client_secret_expires_at).toBe('number');
357+
expect(Number.isFinite(body.client_secret_expires_at)).toBe(true);
358+
}
359+
}, 15_000);
360+
361+
// =========================================================================
362+
// v0.26.2: revoke-client CLI subprocess test
329363
// =========================================================================
364+
//
365+
// Validates the actual CLI router in src/commands/auth.ts, not just the
366+
// database deletion semantics. Codex flagged that a unit test in
367+
// test/oauth.test.ts proves DB DELETE works but does NOT prove the
368+
// subcommand exists or routes correctly.
330369

331-
test('revoke client via admin API invalidates all tokens', async () => {
332-
// Register a disposable client
370+
test('auth revoke-client (CLI) deletes client + cascades to tokens', async () => {
333371
const { execSync } = await import('child_process');
372+
373+
// Step 1: register a throwaway client via CLI.
374+
// env: { ...process.env } per the bun execSync inheritance fix above.
334375
const regOutput = execSync(
335-
'bun run src/cli.ts auth register-client e2e-revoke-test --grant-types client_credentials --scopes "read"',
336-
{ cwd: process.cwd(), encoding: 'utf8' }
376+
'bun run src/cli.ts auth register-client e2e-revoke-cli --grant-types client_credentials --scopes read',
377+
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
337378
);
338-
const id = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/)?.[1];
339-
const secret = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/)?.[1];
340-
expect(id).toBeDefined();
341-
expect(secret).toBeDefined();
379+
const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/);
380+
const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/);
381+
expect(idMatch).not.toBeNull();
382+
expect(secretMatch).not.toBeNull();
383+
const id = idMatch![1];
384+
const secret = secretMatch![1];
342385

343-
// Mint a token — should work
386+
// Step 2: mint a token through the live server.
344387
const tokenRes = await fetch(`${BASE}/token`, {
345388
method: 'POST',
346389
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -349,70 +392,41 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => {
349392
expect(tokenRes.ok).toBe(true);
350393
const { access_token } = await tokenRes.json() as any;
351394

352-
// Verify token works
395+
// Sanity: the freshly-minted token works at /mcp.
353396
const before = await mcpCall(access_token, 'tools/list');
354397
expect(before.status).not.toBe(401);
355398

356-
// Use the magic link to get a session cookie
357-
const authRes = await fetch(`${BASE}/admin/auth/${adminToken}`, { redirect: 'manual' });
358-
const cookie = authRes.headers.get('set-cookie') || '';
359-
360-
const revokeRes = await fetch(`${BASE}/admin/api/revoke-client`, {
361-
method: 'POST',
362-
headers: { 'Content-Type': 'application/json', 'Cookie': cookie },
363-
body: JSON.stringify({ clientId: id }),
364-
});
365-
if (!revokeRes.ok) {
366-
const errBody = await revokeRes.text();
367-
throw new Error(`Revoke failed ${revokeRes.status}: ${errBody}\ncookie: ${cookie.substring(0, 30)}`);
368-
}
369-
const revokeData = await revokeRes.json() as any;
370-
expect(revokeData.revoked).toBe(true);
371-
372-
// Token should no longer work
399+
// Step 3: revoke via the CLI subprocess.
400+
const revokeOutput = execSync(
401+
`bun run src/cli.ts auth revoke-client "${id}"`,
402+
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
403+
);
404+
// The handler prints the human confirmation lines. No exit code != 0
405+
// here since execSync would throw.
406+
expect(revokeOutput).toMatch(/OAuth client revoked/);
407+
expect(revokeOutput).toMatch(/cascade/i);
408+
409+
// Step 4: previously-minted token must now be rejected at /mcp. Cascade
410+
// wiped the oauth_tokens row; verifyAccessToken throws "Invalid token".
411+
// Match the existing pattern at line 156: SDK error mapping varies
412+
// (401/403/500), so we assert non-success status + non-success body
413+
// rather than a single status code.
373414
const after = await mcpCall(access_token, 'tools/list');
415+
expect(after.status).toBeGreaterThanOrEqual(400);
374416
const afterBody = await after.text();
375-
expect(after.status >= 400 || afterBody.includes('invalid_token') || afterBody.includes('error')).toBe(true);
376-
377-
// Minting new tokens should fail
378-
const mintAfter = await fetch(`${BASE}/token`, {
379-
method: 'POST',
380-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
381-
body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`,
382-
});
383-
expect(mintAfter.ok).toBe(false);
384-
}, 30_000);
385-
386-
test('revoke API key via admin API', async () => {
387-
// Get admin session
388-
const authRes = await fetch(`${BASE}/admin/auth/${adminToken}`, { redirect: 'manual' });
389-
const cookie = authRes.headers.get('set-cookie') || '';
417+
expect(afterBody).not.toContain('"tools":[');
390418

391-
// Create key
392-
const createRes = await fetch(`${BASE}/admin/api/api-keys`, {
393-
method: 'POST',
394-
headers: { 'Content-Type': 'application/json', 'Cookie': cookie },
395-
body: JSON.stringify({ name: 'e2e-revoke-key-test' }),
396-
});
397-
expect(createRes.ok).toBe(true);
398-
const { token } = await createRes.json() as any;
399-
expect(token).toBeDefined();
400-
401-
// Token should work at /mcp
402-
const before = await mcpCall(token, 'tools/list');
403-
expect(before.status).not.toBe(401);
404-
405-
// Revoke it
406-
const revokeRes = await fetch(`${BASE}/admin/api/api-keys/revoke`, {
407-
method: 'POST',
408-
headers: { 'Content-Type': 'application/json', 'Cookie': cookie },
409-
body: JSON.stringify({ name: 'e2e-revoke-key-test' }),
410-
});
411-
expect(revokeRes.ok).toBe(true);
412-
413-
// Token should no longer work
414-
const after = await mcpCall(token, 'tools/list');
415-
const afterBody = await after.text();
416-
expect(after.status >= 400 || afterBody.includes('invalid_token') || afterBody.includes('error')).toBe(true);
419+
// Step 5: re-running revoke-client on the now-deleted id must exit 1.
420+
let secondRunFailed = false;
421+
let secondRunStderr = '';
422+
try {
423+
execSync(`bun run src/cli.ts auth revoke-client "${id}"`,
424+
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } });
425+
} catch (e: any) {
426+
secondRunFailed = true;
427+
secondRunStderr = (e.stderr || '').toString() + (e.stdout || '').toString();
428+
}
429+
expect(secondRunFailed).toBe(true);
430+
expect(secondRunStderr).toMatch(/No client found/);
417431
}, 30_000);
418432
});

0 commit comments

Comments
 (0)