Files
gbrain/test/oauth-confidential-client.test.ts
T
6af0c91e53 v0.41.3.0 fix(security/mcp): OAuth CORS lockdown + pre-register without DCR + validator surface (#1403)
* v0.41.3.0 fix(security/mcp): OAuth CORS lockdown, pre-register without DCR, validator surface

Three expanded cherry-picks plus codex-surfaced live-CORS fix, parser
rewrite, atomicity fix, DCR validator gate, SECURITY.md reconciliation.

What ships
- gbrain auth register-client gets --redirect-uri (repeatable) and
  --token-endpoint-auth-method flags so the SECURITY.md-recommended
  "pre-register without --enable-dcr" path actually works for claude.ai
  and ChatGPT custom connectors.
- ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = {client_secret_post,
  client_secret_basic, none} validator gates all three registration
  entry points (CLI, admin endpoint, DCR /register) so --enable-dcr is
  no longer the looser path.
- Live Express OAuth server (/mcp, /token, /authorize, /register,
  /revoke) was using default-wide-open cors() middleware — every
  origin could complete a token exchange from a logged-in operator's
  browser. Now default-deny; allowlist via GBRAIN_HTTP_CORS_ORIGIN.
- GBRAIN_HTTP_TRUST_PROXY env var on Express server with the same
  semantics as the legacy bearer transport already had. Default
  'loopback' preserved. SECURITY.md doc rewritten to match reality
  (was lying that trust proxy was "disabled by default" while code
  hardcoded 'loopback').
- Admin endpoint registration now atomic — INSERT-then-UPDATE for
  public clients replaced with single INSERT via the new
  registerClientManual(..., tokenEndpointAuthMethod) parameter (codex
  outside-voice F4 catch).
- Legacy transport corsHeaders + corsPreflightHeaders consolidated
  into one function gated on the allowlist for BOTH Allow-Origin and
  Allow-Methods/Headers (codex F1; #983 thematically).

Surfaced by D7 codex outside-voice review on the v0.41.3 plan:
F1 (live Express CORS wide-open), F2 (indexOf parser couldn't do
repeatable flags), F3 (client_secret_basic missing from validator),
F4 (admin endpoint INSERT-then-UPDATE atomicity), F5 (DCR path
bypassed validator), F6 (env var already existed on legacy transport),
F7 (SECURITY.md vs impl doc disagreement).

Tests: 183 directly-touched cases green. Three new test files
(test/serve-http-trust-proxy.test.ts, test/serve-http-cors.test.ts,
test/auth-register-client-args.test.ts) + 18 new oauth.test.ts cases
+ 4 IRON RULE CORS preflight regressions.

Plan: ~/.claude/plans/system-instruction-you-are-working-wise-piglet.md
(D1-D11 captured, codex outside-voice integrated, GSTACK REVIEW REPORT
verdict CLEARED).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(test): audit-writer readRecent calendar-boundary flake

writer.log() uses real `new Date()` for filename computation, but the
test mocked `now` to 2026-05-22. When CI runs on a date in a different
ISO week (e.g. 2026-05-25 W22 vs the mocked W21), log() writes to one
file but readRecent(now) reads a different one — zero events overlap,
expect(2).toBe(0) fails.

Fix: write events directly to the file matching the test's mocked
`now` via writer.computeFilename(now), same pattern the cross-week
straddle test (line 234+) already used for the previous-week event.

Pre-existing test bug, surfaced when CI rolled past the week boundary
the original author wrote against. Not introduced by v0.41.3.0; fix
included here because /ship found it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:57:30 -07:00

144 lines
6.2 KiB
TypeScript

/**
* v0.37.7.0 #1166 — OAuth confidential clients regression test.
*
* The MCP SDK's clientAuth middleware does `client.client_secret !==
* presented_secret` plaintext compare. gbrain stores SHA-256 hashes,
* so the SDK's compare always failed for confidential authorization_code
* and refresh_token grants. v0.34.1.0 fixed PUBLIC PKCE clients
* (client_secret = undefined); confidential clients regressed.
*
* Fix: provider gains `verifyConfidentialClientSecret(clientId, secret)`
* that does hash-then-compare ourselves. The serve-http /token middleware
* uses this BEFORE delegating to exchangeAuthorizationCode /
* exchangeRefreshToken. Public clients fall through to the SDK as today.
*
* Hermetic via PGLite in-memory.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts';
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
let engine: PGLiteEngine;
let provider: GBrainOAuthProvider;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
provider = new GBrainOAuthProvider({ sql: sqlQueryForEngine(engine) });
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await (engine as any).db.exec('DELETE FROM oauth_tokens');
await (engine as any).db.exec('DELETE FROM oauth_codes');
await (engine as any).db.exec('DELETE FROM oauth_clients');
});
describe('verifyConfidentialClientSecret (#1166)', () => {
test('confidential client_secret_post: returns client on correct secret', async () => {
const reg = await provider.registerClientManual('test-conf', ['authorization_code'], 'read write', ['https://example.test/cb']);
expect(reg.clientId).toBeTruthy();
expect(reg.clientSecret!).toBeTruthy();
const client = await provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret!);
expect(client.client_id).toBe(reg.clientId);
});
test('wrong secret → throws "Invalid client" (RFC 6749 opaque error)', async () => {
const reg = await provider.registerClientManual('test-conf', ['authorization_code'], 'read', ['https://example.test/cb']);
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, 'wrong-secret'),
).rejects.toThrow(/Invalid client/);
});
test('non-existent client → throws "Invalid client"', async () => {
await expect(
provider.verifyConfidentialClientSecret('does-not-exist', 'anything'),
).rejects.toThrow(/Invalid client/);
});
test('public client (token_endpoint_auth_method=none) refuses confidential path', async () => {
// Public PKCE clients are registered via the SDK's DCR path with
// `token_endpoint_auth_method: 'none'` — those store
// client_secret_hash = NULL. registerClientManual sets a secret
// unconditionally, so we test the rejection by directly inserting
// a public-client row.
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method)
VALUES ('public-pkce', NULL, 'public', $1, $2, 'read', 'none')`,
[
['https://example.test/cb'],
['authorization_code'],
],
);
await expect(
provider.verifyConfidentialClientSecret('public-pkce', 'any-secret'),
).rejects.toThrow(/Invalid client/);
});
test('case-insensitive secret? NO — must be exact match', async () => {
const reg = await provider.registerClientManual('test-case', ['authorization_code'], 'read', ['https://example.test/cb']);
const wrongCase = reg.clientSecret!.toUpperCase();
if (wrongCase !== reg.clientSecret!) {
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, wrongCase),
).rejects.toThrow(/Invalid client/);
}
});
test('soft-deleted client → throws "Client has been revoked"', async () => {
const reg = await provider.registerClientManual('to-revoke', ['authorization_code'], 'read', ['https://example.test/cb']);
await engine.executeRaw(
`UPDATE oauth_clients SET deleted_at = NOW() WHERE client_id = $1`,
[reg.clientId],
);
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret!),
).rejects.toThrow(/revoked/);
});
});
describe('confidential-client full flow #1166', () => {
test('verify-then-exchange refresh token end-to-end', async () => {
const reg = await provider.registerClientManual('full-flow-rt', ['authorization_code', 'refresh_token'], 'read', ['https://example.test/cb']);
// Mint an initial token pair via client_credentials (simpler than
// /authorize round-trip in a unit test).
await engine.executeRaw(
`UPDATE oauth_clients SET grant_types = $1 WHERE client_id = $2`,
[['client_credentials', 'refresh_token'], reg.clientId],
);
const initial = await provider.exchangeClientCredentials(reg.clientId, reg.clientSecret!, 'read');
// client_credentials grants don't issue refresh tokens (RFC 6749
// 4.4.3), so we manually insert a refresh token to test the
// verify-then-rotate path.
const refreshToken = 'rt_' + Buffer.from(Math.random().toString()).toString('hex');
const { hashToken } = await import('../src/core/utils.ts');
await engine.executeRaw(
`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES ($1, 'refresh', $2, $3, $4)`,
[hashToken(refreshToken), reg.clientId, ['read'], Math.floor(Date.now() / 1000) + 3600],
);
// verify → exchange round-trip with the correct secret
const client = await provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret!);
const rotated = await provider.exchangeRefreshToken(client, refreshToken);
expect(rotated.access_token).toBeTruthy();
expect(rotated.refresh_token).toBeTruthy();
expect(rotated.refresh_token).not.toBe(refreshToken); // rotated
// Original refresh token is now consumed; second use rejected.
await expect(
provider.exchangeRefreshToken(client, refreshToken),
).rejects.toThrow(/not found/);
});
});