mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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
This commit is contained in:
@@ -80,8 +80,8 @@ export function AgentsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.filter(a => !hideRevoked || a.status !== 'revoked').map(a => (
|
||||
<tr key={a.id} onClick={() => a.auth_type === 'oauth' ? setSelectedAgent(a) : null}
|
||||
style={{ cursor: a.auth_type === 'oauth' ? 'pointer' : 'default' }}>
|
||||
<tr key={a.id} onClick={() => setSelectedAgent(a)}
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 500 }}>{a.name || a.client_name}</td>
|
||||
<td>
|
||||
<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: ()
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{isOAuth && (<>
|
||||
<div className="section-title">Config Export</div>
|
||||
<div className="tabs" style={{ flexWrap: 'wrap' }}>
|
||||
<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: ()
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{configSnippets[tab]}</pre>
|
||||
<button className="copy-btn" onClick={() => copy(configSnippets[tab])}>Copy</button>
|
||||
</div>
|
||||
</>)}
|
||||
{!isOAuth && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 16 }}>
|
||||
API keys use simple bearer token auth. The token was shown once at creation.<br/>
|
||||
Use with: <code style={{ background: 'rgba(0,0,0,0.3)', padding: '2px 6px', borderRadius: 4 }}>Authorization: Bearer <key></code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 32 }}>
|
||||
{agent.status === 'active' && (
|
||||
|
||||
+134
-120
@@ -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<typeof import('child_process').spawn> | 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);
|
||||
});
|
||||
|
||||
+66
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user