Files
gbrain/test/oauth.test.ts
T
488e4824e8 v0.34.1.0 fix(mcp): MCP fix wave — source-isolation P0 + PKCE DCR + federated_read + 3 more (#996)
* fix(mcp): skip stdin EOF handlers when MCP_STDIO=1

OpenClaw's bundle-mcp gateway and similar wrappers pipe the JSON-RPC
handshake on stdin then close their stdin half. Pre-fix, both stdin
'end' and 'close' listeners (server.ts:65-66 and serve.ts:204-206)
treated this as a permanent disconnect and shut the server down before
the first tool call arrived.

Guard both sites with `process.env.MCP_STDIO !== '1'`. Signal handlers
(SIGTERM/SIGINT/SIGHUP), transport.onclose, and the parent-process
watchdog still cover legitimate shutdown paths. The serve.ts site
threads the env read through an injectable `mcpStdio?: boolean` on
ServeOptions so tests stay isolated (no process.env mutation per
scripts/check-test-isolation.sh R1).

Tests: 3 new cases in test/serve-stdio-lifecycle.test.ts pin the
guard's invariants — mcpStdio=true must NOT trigger shutdown on stdin
EOF, signals must still drive shutdown with mcpStdio=true, and
mcpStdio=false (default) preserves existing CLI behavior. 25/25 pass.

Origin: PR #870.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): honor token_endpoint_auth_method=none for PKCE public clients

RFC 7591 §3.2.1: when a DCR client declares
token_endpoint_auth_method="none" (PKCE-only public clients like Claude
Code, Cursor), the authorization server MUST NOT issue a client_secret.
Pre-fix, registerClient unconditionally minted a secret, and the MCP
SDK's clientAuth middleware then rejected valid public-client flows on
/token because it expected client.client_secret to match.

Three changes to src/core/oauth-provider.ts:registerClient:

  - Gate clientSecret generation on isPublicClient = (auth_method === 'none').
    Public clients store client_secret_hash = NULL.
  - Omit client_secret from the response payload for public clients.
    Confidential clients (default client_secret_post and explicit
    client_secret_basic) keep their existing one-time-reveal shape.
  - Normalize NULL secret_hash to JS undefined in getClient so SDK
    middleware (which checks client.client_secret === undefined, not
    === null) correctly identifies public clients and skips the
    secret-comparison branch on /token.

Schema is already permissive (client_secret_hash TEXT, no NOT NULL on
both src/schema.sql and src/core/pglite-schema.ts) — no migration
needed.

Tests: 5 new cases in test/oauth.test.ts pin:
  - public client → no client_secret in response (#11 from plan)
  - default auth_method → secret unchanged (regression guard)
  - explicit client_secret_post → secret unchanged
  - getClient NULL→undefined normalization
  - PKCE full /authorize → /token end-to-end with no secret (#15 from plan)

69/69 oauth.test.ts cases pass. typecheck clean.

Origin: PR #909.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(serve-http): --bind HOST, default to loopback (127.0.0.1)

Adds `gbrain serve --http --bind <interface>` to control which network
interface the HTTP MCP server listens on. Default flipped from
`0.0.0.0` (pre-v0.34) to `127.0.0.1` (v0.34.0+).

Why the flip: gbrain's primary use case is a personal-knowledge brain on
a laptop. The previous default exposed brains on every interface — one
accidental `--http` invocation away from publishing the brain to a LAN.
Server operators who need remote access pass `--bind 0.0.0.0` (or a
specific interface). Codex's outside-voice on the original PR #864
correctly flagged that the additive flag wasn't actually the fix; the
default needed to change for the safety claim to hold.

If `--public-url` is set but `--bind` is unset, runServeHttp prints a
loud stderr WARN at startup recommending `--bind 0.0.0.0`. Declaring a
public URL while quietly binding loopback is almost always a
misconfiguration; we want the operator to see it on first start, not
silently fail remote requests.

Startup banner now includes a `Bind:` row so the listening interface is
visible alongside Port / Engine / Issuer.

Origin: PR #864, extended with D11 (default flip) per /plan-eng-review
codex outside-voice review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): seal source-isolation leak on read path (P0)

Pre-fix, an authenticated OAuth MCP client scoped to source-A could
enumerate source-B pages via six read-side ops: search, query (text
AND image paths), list_pages, traverse_graph, and find_experts. The
v0.31.8 source-scoping pattern shipped through dispatch.ts but the op
handlers never threaded ctx.sourceId into their engine calls, and
hybridSearch.ts:223's explicit SearchOpts rebuild dropped sourceId
even when callers passed it.

Sealing the leak:

  - src/core/operations.ts adds sourceScopeOpts(ctx), the canonical
    precedence ladder: ctx.auth.allowedSources (federated) wins over
    ctx.sourceId (scalar) wins over nothing. Threaded into all 5
    read-side op handlers + the query-image-path searchVector call
    (the 6th leak surface codex caught in plan review).

  - src/core/search/hybrid.ts:223 now threads sourceId + sourceIds
    fields through the inner SearchOpts rebuild. The explicit pick
    shape is preserved (HNSW inner-CTE ordering depends on it) but
    extended.

  - src/core/types.ts adds sourceIds?: string[] to SearchOpts +
    PageFilters (D9: federated read needs array-shaped engine filter
    or fan-out; array wins for hot retrieval).

  - src/core/operations.ts AuthInfo gains sourceId + allowedSources
    (D2: identity surface symmetric with the federated_read column
    #876 will add).

  - Both engines now apply WHERE source_id = $N (scalar) or = ANY($N::text[])
    (array) at the SQL layer for searchKeyword, searchKeywordChunks,
    searchVector, listPages, traverseGraph, traversePaths. Array form
    wins when both are set. The searchVector filter pushes into the
    inner HNSW CTE (codex flagged this placement during plan review).

  - traverseGraph + traversePaths signatures gain opts.sourceId +
    opts.sourceIds; engine.ts interface updated.

  - findExperts (the whoknows op, D3 5th leak surface) accepts
    sourceId + sourceIds and threads them into its internal
    hybridSearch call. PR #861 was authored before v0.33 shipped so
    this op wasn't covered in the original PR.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken populates AuthInfo.sourceId
    from oauth_clients.source_id. JOIN guarded by isUndefinedColumnError
    so pre-v55 brains degrade to legacy projection rather than refusing
    every token verification.

  - GBrainOAuthProvider.registerClientManual gains a sourceId
    parameter (defaults to 'default'). DCR registerClient also sets
    source_id='default' on the inserted row.

  - serve-http.ts:929 cleanup: AuthInfo.sourceId is now a real typed
    field. The cast + GBRAIN_SOURCE env fallback chain is gone (D13).
    Legacy bearer tokens default to 'default' source in
    verifyAccessToken.

  - http-transport.ts (legacy access_tokens path) threads
    sourceId='default' through DispatchOpts so v0.22.7 callers stay
    source-scoped.

  - auth.ts CLI adds --source flag to gbrain auth register-client.

Migration v55 (D10 + D13):

  - ALTER TABLE oauth_clients ADD COLUMN source_id TEXT (nullable).
  - Backfill UPDATE source_id = 'default' WHERE source_id IS NULL —
    preserves v0.33 effective behavior verbatim for legacy clients.
  - ADD CONSTRAINT FK ... REFERENCES sources(id) ON DELETE SET NULL,
    wrapped in DO block so re-runs against fresh-install brains (where
    the FK already lives inline in SCHEMA_SQL) no-op cleanly.
  - CREATE INDEX idx_oauth_clients_source_id WHERE source_id IS NOT NULL
    for the verifyAccessToken JOIN.
  - GBRAIN_ACCEPT_SILENT_WIDEN env-flag wired through the runner via
    SET LOCAL gbrain.accept_silent_widen — reserved for future migrations
    that hit the silent-widen footgun codex flagged. This migration
    doesn't need it (column is brand new; no pre-existing stale values
    possible by definition).
  - src/core/pglite-schema.ts + src/schema.sql include the column +
    FK + index inline for fresh installs.

Tests: new test/e2e/source-isolation-pglite.test.ts with 13 regression
cases — one per leak surface (search/list_pages/traverse/etc.) plus
explicit AuthInfo.sourceId and AuthInfo.allowedSources op-handler
threading checks. Full unit suite: 6034 pass / 0 fail. PGLite
initSchema time dropped from 2.4s to 850ms after consolidating v55's
DO blocks (multiple DO blocks were slow on PGLite; one DO block for
the FK install only is fine).

Origin: PR #861 + plan-eng-review decisions D2/D3/D4/D9/D10/D13 + F2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gateway): multimodal embedding for openai-compatible providers

Pre-fix, embedMultimodal hardcoded a recipe.id === 'voyage' branch and
threw AIConfigError for every other recipe. Multimodal-capable providers
fronted by LiteLLM (or any openai-compatible proxy) were unreachable
even when the operator had wired up the model.

The fix:

  - src/core/ai/gateway.ts adds embedMultimodalOpenAICompat() that
    POSTs to the standard /embeddings endpoint with content arrays
    carrying image_url entries. Routing comes from the existing
    recipe.implementation switch — Voyage stays on its own
    /multimodalembeddings path; every other openai-compatible recipe
    flows through the new helper.

  - src/core/ai/recipes/litellm-proxy.ts declares
    supports_multimodal: true so embedMultimodal accepts the recipe.
    No multimodal_models allow-list: LiteLLM is a passthrough proxy
    and the user owns model-id selection; provider rejection (400 from
    upstream) is the right enforcement layer there. Voyage's static
    allow-list shape stays unchanged (its 12 models share
    supports_multimodal but only one is multimodal-capable).

  - D12 runtime dimension validation: the new helper checks the
    returned vector length against the recipe's declared default_dims
    (preferred) or the brain's embedding_dimensions config. Mismatch
    throws AIConfigError with model id + observed + expected so the
    operator can swap models or rebuild the column. Pre-fix, a
    wrong-dim response would surface as a cryptic pgvector
    "vector dimension mismatch" at INSERT time.

  - Auth resolution routes through the existing defaultResolveAuth
    helper so optional-auth recipes (LiteLLM proxy with no
    LITELLM_API_KEY) and required-auth recipes both share one code
    path. Optional-auth sends "Authorization: Bearer unauthenticated"
    which servers like Ollama / llama-server ignore but the SDK
    contract requires.

Tests: 11 new cases in test/openai-compat-multimodal.test.ts cover
happy-path, multi-input batching, unauthenticated proxy, D12 dim
mismatch + default-dim fallback, 401 / 400 / malformed-JSON / non-array
error paths, and an explicit Voyage-regression test pinning that the
new openai-compat route doesn't accidentally hijack the Voyage path.
All 41 multimodal-related tests pass (existing voyage suite + new).
typecheck clean.

Origin: PR #875 + plan-eng-review D12 (runtime dim validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(oauth): federated_read read scope (#876)

Pre-fix, OAuth clients had a single source-scope axis (source_id, added
in v55). A client could either write+read one source OR be a super-reader
across all sources (via NULL source_id). There was no middle ground —
WeCare-style L3 dept clients that need to write to dept-x but read
dept-x + parent canon + shared canon had no expression.

#876 adds federated_read TEXT[] as an orthogonal read-scope axis. source_id
is the WRITE authority; federated_read is the READ authority. They default
to matching values (read scope == write scope, the pre-v0.34 default)
when a client is registered without an explicit federated read list.

Migrations v56-v60 (six new migrations on top of v55):

  - v56: ALTER TABLE ... ADD COLUMN federated_read TEXT[] NOT NULL DEFAULT '{}'.
  - v57 (F5): explicit CASE backfill so source_id IS NULL → '{}' (not an
    array containing NULL — codex caught this ambiguity during plan review).
  - v58: post-backfill validation. Fails loud if any row's source_id isn't
    in its federated_read array, pointing at a logic bug in v57 if fired.
  - v59: flip the source_id FK from ON DELETE SET NULL to ON DELETE
    RESTRICT now that federated_read provides the alternative scope-loss
    path. Pre-flip, deleting a source could silently widen any oauth_client
    to super-reader; post-flip, source delete is refused if any client
    references it (operator must revoke/re-scope first).
  - v60: GIN index on federated_read for array-containment queries.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken JOINs c.federated_read and
    populates AuthInfo.allowedSources. Pre-v56 / pre-v55 brains degrade
    via the existing isUndefinedColumnError fallback chain.
  - registerClientManual gains a federatedRead?: string[] parameter
    (defaults to [sourceId]).
  - DCR registerClient sets source_id='default' + federated_read=['default']
    on the inserted row.
  - auth.ts CLI adds --federated-read SRC1,SRC2,... flag. The
    register-client output now prints "Federated reads:" so operators
    confirm the scope they set.

Engines consume the federated array through the SearchOpts.sourceIds /
PageFilters.sourceIds field that #861 added (no engine changes here — the
plumbing was D9). sourceScopeOpts in operations.ts already prefers the
auth.allowedSources array over scalar ctx.sourceId when set.

Test seam:
  - test/book-mirror.test.ts now spawns the CLI with GBRAIN_HOME pointed
    at a tempdir so the test isn't sensitive to the developer's local
    ~/.gbrain/config.json. Pre-fix the test could silently inherit a real
    Postgres connection and hang past the default 5s test timeout. Fresh
    GBRAIN_HOME → "No brain configured" → exit 1 in <1s.
  - test/e2e/source-isolation-pglite.test.ts gains one more regression
    case: AuthInfo.allowedSources = [] (explicit empty) MUST NOT widen
    scope to "all sources" — the silent-widen footgun precedence ladder.
  - test/openai-compat-multimodal.test.ts is part of the wave's commits
    via the migrate.ts changes that bump the schema chain. typecheck-only
    fix on a captured-auth type was already in #875's tree.

6045 unit tests pass / 0 fail. typecheck clean. PGLite initSchema runs
v55-v60 in ~786ms total (within the test-harness budget for tests using
the canonical beforeAll engine pattern).

Origin: PR #876 + plan-eng-review F5 (CASE backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.34.0.0: MCP fix wave (#870 #909 #864 #861 #875 #876)

VERSION + package.json + CHANGELOG bump for the six-PR MCP fix wave.
Schema chain extends from v54 → v60; oauth_clients gains source_id +
federated_read columns; auth'd MCP clients now stay inside their scope
across all read-side ops; PKCE-only DCR works; --bind defaults to
loopback; LiteLLM multimodal embedding ships.

Contributed by @Hansen1018 (#870), @ding-modding (#909), @DukeDawg
(#864), @toilalesondev (#861 + #876), @yoelgal (#875).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.34.0.0

Sync README, CLAUDE.md, SECURITY.md, docs/architecture/topologies.md,
and docs/mcp/DEPLOY.md to reflect the v0.34.0.0 MCP fix wave:

- README: document --bind HOST default (loopback), --source +
  --federated-read register-client flags, PKCE public-client gate
- SECURITY.md: note loopback-by-default for serve --http, update the
  trust-proxy contract to point at the new default
- CLAUDE.md: annotate operations.ts (sourceScopeOpts helper),
  oauth-provider.ts (verifyAccessToken JOIN + PKCE public clients),
  serve-http.ts (--bind flag), gateway.ts (openai-compat multimodal +
  dim validation), mcp/server.ts (MCP_STDIO guard), auth.ts (--source
  + --federated-read), migrate.ts (v58-v63 chain), engine.ts
  (sourceIds field). Add 4 new test-file entries for
  source-isolation-pglite, openai-compat-multimodal,
  serve-stdio-lifecycle, oauth.test.ts PKCE cases
- docs/architecture/topologies.md: source-scoped register-client
  example, --bind 0.0.0.0 for thin-client host setup
- docs/mcp/DEPLOY.md: --bind explanation in the ngrok section,
  source-scoped client recipe
- llms-full.txt: regenerated per the CLAUDE.md-edit chaser rule

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.34.0.0 → v0.34.1.0

Renumbering the MCP fix wave from v0.34.0.0 to v0.34.1.0 so the
release slot lands between master's v0.33.2.1 and the next minor.

Touches every release-artifact mention:
- VERSION: 0.34.0.0 → 0.34.1.0
- package.json: same
- CHANGELOG.md header + "To take advantage" block
- CLAUDE.md key-files annotations (8 entries that document this wave)
- llms-full.txt (regen from CLAUDE.md)
- README.md / SECURITY.md / docs/architecture/topologies.md / docs/mcp/DEPLOY.md
- Wave code-comment markers ("// v0.34.0 (#NNN):" → "// v0.34.1 (#NNN):")

Test files renamed alongside since they were committed with the wave.

Commit subjects on the original 6 PR commits + the v0.34.0.0 bump
commit (4f533c726b47db7e) intentionally NOT rewritten — those are
history. `git log` finds the implementation by message subject, not by
version tag.

6275 unit tests pass, typecheck clean, migration chain v58-v63 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:15:29 -07:00

1254 lines
53 KiB
TypeScript

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, 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';
// ---------------------------------------------------------------------------
// Test setup: in-memory PGLite with OAuth tables
// ---------------------------------------------------------------------------
let db: PGlite;
let sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<any>;
let provider: GBrainOAuthProvider;
beforeAll(async () => {
db = new PGlite({ extensions: { vector, pg_trgm } });
await db.exec(PGLITE_SCHEMA_SQL);
// Create a tagged template wrapper for PGLite
sql = async (strings: TemplateStringsArray, ...values: unknown[]) => {
const query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? `$${i + 1}` : ''), '');
const result = await db.query(query, values as any[]);
return result.rows;
};
provider = new GBrainOAuthProvider({ sql, tokenTtl: 60, refreshTtl: 300 });
}, 30_000); // PGLITE_SCHEMA_SQL execution under full-suite load can exceed default 5s
afterAll(async () => {
if (db) await db.close();
}, 15_000);
// ---------------------------------------------------------------------------
// hashToken + generateToken utilities
// ---------------------------------------------------------------------------
describe('hashToken', () => {
test('produces consistent SHA-256 hex', () => {
const hash = hashToken('test-token');
expect(hash).toHaveLength(64);
expect(hashToken('test-token')).toBe(hash); // deterministic
});
test('different inputs produce different hashes', () => {
expect(hashToken('a')).not.toBe(hashToken('b'));
});
});
describe('generateToken', () => {
test('produces prefixed random hex', () => {
const token = generateToken('gbrain_cl_');
expect(token).toStartWith('gbrain_cl_');
expect(token).toHaveLength('gbrain_cl_'.length + 64); // 32 bytes = 64 hex chars
});
test('tokens are unique', () => {
const a = generateToken('test_');
const b = generateToken('test_');
expect(a).not.toBe(b);
});
});
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
describe('client registration', () => {
test('registerClientManual creates a client', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'test-agent', ['client_credentials'], 'read write',
);
expect(clientId).toStartWith('gbrain_cl_');
expect(clientSecret).toStartWith('gbrain_cs_');
// Verify client exists in DB
const client = await provider.clientsStore.getClient(clientId);
expect(client).toBeDefined();
expect(client!.client_name).toBe('test-agent');
});
test('getClient returns undefined for unknown client', async () => {
const client = await provider.clientsStore.getClient('nonexistent');
expect(client).toBeUndefined();
});
test('duplicate client_id is rejected', async () => {
const { clientId } = await provider.registerClientManual(
'dup-test', ['client_credentials'], 'read',
);
// Try to insert same client_id directly
await expect(
sql`INSERT INTO oauth_clients (client_id, client_name, scope) VALUES (${clientId}, ${'dup'}, ${'read'})`,
).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// Client Credentials Exchange
// ---------------------------------------------------------------------------
describe('client credentials', () => {
let clientId: string;
let clientSecret: string;
beforeAll(async () => {
const result = await provider.registerClientManual(
'cc-test-agent', ['client_credentials'], 'read write',
);
clientId = result.clientId;
clientSecret = result.clientSecret;
});
test('valid exchange returns access token', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
expect(tokens.access_token).toStartWith('gbrain_at_');
expect(tokens.token_type).toBe('bearer');
expect(tokens.expires_in).toBe(60);
expect(tokens.scope).toBe('read');
});
test('no refresh token issued for CC grant', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
expect(tokens.refresh_token).toBeUndefined();
});
test('wrong secret is rejected', async () => {
await expect(
provider.exchangeClientCredentials(clientId, 'wrong-secret', 'read'),
).rejects.toThrow('Invalid client secret');
});
test('client without CC grant is rejected', async () => {
const { clientId: noCC } = await provider.registerClientManual(
'no-cc-agent', ['authorization_code'], 'read',
);
await expect(
provider.exchangeClientCredentials(noCC, 'any-secret', 'read'),
).rejects.toThrow('not authorized');
});
test('scope is filtered to allowed scopes', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read write admin');
// Client only has 'read write', admin should be filtered out
expect(tokens.scope).not.toContain('admin');
});
});
// ---------------------------------------------------------------------------
// Token Verification
// ---------------------------------------------------------------------------
describe('verifyAccessToken', () => {
test('valid token returns auth info', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'verify-test', ['client_credentials'], 'read write',
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(authInfo.clientId).toBe(clientId);
expect(authInfo.scopes).toContain('read');
expect(authInfo.token).toBe(tokens.access_token);
});
test('expired token is rejected', async () => {
// Insert a token that's already expired
const expiredToken = generateToken('gbrain_at_');
const hash = hashToken(expiredToken);
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}'}, ${Math.floor(Date.now() / 1000) - 100})
`;
await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow('expired');
});
test('unknown token is rejected', async () => {
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.
// verifyAccessToken must cast to Number() before returning.
const { clientId, clientSecret } = await provider.registerClientManual(
'typeof-test', ['client_credentials'], 'read',
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(typeof authInfo.expiresAt).toBe('number');
expect(Number.isNaN(authInfo.expiresAt)).toBe(false);
expect(authInfo.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
});
test('legacy access_tokens fallback works', async () => {
// Insert a legacy bearer token
const legacyToken = generateToken('gbrain_');
const hash = hashToken(legacyToken);
await sql`
INSERT INTO access_tokens (id, name, token_hash)
VALUES (${crypto.randomUUID()}, ${'legacy-agent'}, ${hash})
`;
const authInfo = await provider.verifyAccessToken(legacyToken);
expect(authInfo.clientId).toBe('legacy-agent');
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access
});
});
// ---------------------------------------------------------------------------
// Token Revocation
// ---------------------------------------------------------------------------
describe('revokeToken', () => {
test('revoked token no longer verifies', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'revoke-test', ['client_credentials'], 'read',
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
// Verify token works
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(authInfo.clientId).toBe(clientId);
// Revoke it
const client = (await provider.clientsStore.getClient(clientId))!;
await provider.revokeToken!(client, { token: tokens.access_token });
// Should no longer verify
await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow();
});
test('revoking already-revoked token is a no-op', async () => {
// This should not throw
const client = (await provider.clientsStore.getClient(
(await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0].client_id as string,
))!;
await provider.revokeToken!(client, { token: 'already-gone' });
// No error = pass
});
});
// ---------------------------------------------------------------------------
// Authorization Code Flow
// ---------------------------------------------------------------------------
describe('authorization code flow', () => {
test('code issuance and exchange', async () => {
const { clientId } = await provider.registerClientManual(
'authcode-test', ['authorization_code'], 'read write',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
// Mock Express response for authorize
let redirectUrl = '';
const mockRes = {
redirect: (url: string) => { redirectUrl = url; },
} as any;
await provider.authorize(client, {
codeChallenge: 'test-challenge-hash',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read', 'write'],
state: 'test-state',
}, mockRes);
expect(redirectUrl).toContain('code=gbrain_code_');
expect(redirectUrl).toContain('state=test-state');
// Extract code from redirect URL
const url = new URL(redirectUrl);
const code = url.searchParams.get('code')!;
// Exchange code for tokens
const tokens = await provider.exchangeAuthorizationCode(client, code);
expect(tokens.access_token).toStartWith('gbrain_at_');
expect(tokens.refresh_token).toBeDefined(); // Auth code flow includes refresh
});
test('code is single-use', async () => {
const { clientId } = await provider.registerClientManual(
'single-use-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
// First exchange works
await provider.exchangeAuthorizationCode(client, code);
// Second exchange fails (code consumed)
await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow();
});
test('expired code is rejected', async () => {
// Insert an already-expired code
const expiredCode = generateToken('gbrain_code_');
const hash = hashToken(expiredCode);
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
await sql`
INSERT INTO oauth_codes (code_hash, client_id, scopes, code_challenge,
redirect_uri, expires_at)
VALUES (${hash}, ${firstClient.client_id as string}, ${'{read}'},
${'challenge'}, ${'http://localhost/cb'}, ${Math.floor(Date.now() / 1000) - 100})
`;
const client = (await provider.clientsStore.getClient(firstClient.client_id as string))!;
await expect(provider.exchangeAuthorizationCode(client, expiredCode)).rejects.toThrow();
});
// F-AUTHZ regression. The MCP SDK's authorize handler splits `?scope=...`
// verbatim and forwards the raw list to the provider, so the provider must
// clamp against the client's registered grant. Pre-fix the INSERT into
// oauth_codes used `params.scopes || []` raw, so a `read`-registered client
// requesting `?scope=admin` got an admin access token at /token exchange.
// This pins the parallel posture to client_credentials' filter pattern
// (line 513-515) and refresh's F3 subset enforcement (RFC 6749 §6).
test('authorize clamps requested scopes against client.scope (RFC 6749 §3.3)', async () => {
const { clientId } = await provider.registerClientManual(
'authz-clamp-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
// Read-only client requests admin via the SDK's parsed scopes array.
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read', 'write', 'admin'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
// The token's stored scopes must equal the clamped subset.
const auth = await provider.verifyAccessToken(tokens.access_token);
expect(auth.scopes).toEqual(['read']);
expect(auth.scopes).not.toContain('write');
expect(auth.scopes).not.toContain('admin');
});
test('authorize subset request returns subset', async () => {
const { clientId } = await provider.registerClientManual(
'authz-subset-test', ['authorization_code'], 'read write',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
const auth = await provider.verifyAccessToken(tokens.access_token);
expect(auth.scopes).toEqual(['read']);
});
// CSO finding #2 regression. The pre-fix SELECT-then-DELETE pattern let two
// concurrent token requests with the same code both pass the SELECT, both
// running DELETE (no-op on second) and both calling issueTokens. The fix is
// DELETE...RETURNING in one statement; this test fires N=10 concurrent
// exchanges and asserts exactly one succeeds.
test('concurrent exchange requests: only one succeeds (TOCTOU race)', async () => {
const { clientId } = await provider.registerClientManual(
'toctou-code-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const N = 10;
const results = await Promise.allSettled(
Array.from({ length: N }, () => provider.exchangeAuthorizationCode(client, code)),
);
const successes = results.filter(r => r.status === 'fulfilled');
const failures = results.filter(r => r.status === 'rejected');
expect(successes.length).toBe(1);
expect(failures.length).toBe(N - 1);
});
});
// ---------------------------------------------------------------------------
// Refresh Token
// ---------------------------------------------------------------------------
describe('refresh token', () => {
test('valid refresh rotates tokens', async () => {
const { clientId } = await provider.registerClientManual(
'refresh-test', ['authorization_code'], 'read write',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read', 'write'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
// Refresh
const newTokens = await provider.exchangeRefreshToken(client, tokens.refresh_token!, ['read']);
expect(newTokens.access_token).not.toBe(tokens.access_token);
expect(newTokens.refresh_token).toBeDefined();
expect(newTokens.refresh_token).not.toBe(tokens.refresh_token); // rotated
// Old refresh token should no longer work
await expect(provider.exchangeRefreshToken(client, tokens.refresh_token!)).rejects.toThrow();
});
// CSO finding #3 regression. Same TOCTOU pattern as auth code; the fix is
// DELETE...RETURNING. Detection of stolen refresh tokens (RFC 6749 §10.4)
// depends on second-use failure, so two concurrent succeed = no detection.
test('concurrent refresh requests: only one succeeds (TOCTOU race)', async () => {
const { clientId } = await provider.registerClientManual(
'toctou-refresh-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
const N = 10;
const results = await Promise.allSettled(
Array.from({ length: N }, () => provider.exchangeRefreshToken(client, tokens.refresh_token!)),
);
const successes = results.filter(r => r.status === 'fulfilled');
expect(successes.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Token Sweep
// ---------------------------------------------------------------------------
describe('sweepExpiredTokens', () => {
test('removes expired tokens', async () => {
// Insert some expired tokens
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
const expired1 = hashToken(generateToken('sweep_'));
const expired2 = hashToken(generateToken('sweep_'));
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES (${expired1}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${1})`;
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES (${expired2}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${2})`;
await provider.sweepExpiredTokens();
// Verify they're gone
const remaining = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE expires_at < 100`;
expect(remaining[0].count).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Scope Annotations
// ---------------------------------------------------------------------------
describe('operation scope annotations', () => {
test('all operations have a scope', () => {
const { operations } = require('../src/core/operations.ts');
for (const op of operations) {
expect(op.scope, `${op.name} missing scope`).toBeDefined();
// v0.28 added sources_admin and users_admin to the union.
expect([
'read', 'write', 'admin', 'sources_admin', 'users_admin',
]).toContain(op.scope);
}
});
test('mutating operations are write/admin/sources_admin/users_admin scoped', () => {
const { operations } = require('../src/core/operations.ts');
for (const op of operations) {
if (op.mutating) {
// v0.28: sources_admin permits sources_add / sources_remove (mutating
// sources, not pages); read scope is the only thing too narrow for
// any mutating op.
expect(
['write', 'admin', 'sources_admin', 'users_admin'],
`${op.name} is mutating but not a write-axis scope`,
).toContain(op.scope);
}
}
});
test('sync_brain and file_upload are localOnly', () => {
const { operationsByName } = require('../src/core/operations.ts');
expect(operationsByName.sync_brain.localOnly).toBe(true);
expect(operationsByName.file_upload.localOnly).toBe(true);
});
test('file_list and file_url are localOnly', () => {
const { operationsByName } = require('../src/core/operations.ts');
expect(operationsByName.file_list.localOnly).toBe(true);
expect(operationsByName.file_url.localOnly).toBe(true);
});
});
// ---------------------------------------------------------------------------
// CSO finding #5 — pgArray escape + DCR redirect_uri validation
// ---------------------------------------------------------------------------
describe('redirect_uri validation (DCR)', () => {
test('http://localhost is allowed (loopback exception)', async () => {
const result = await provider.clientsStore.registerClient!({
client_name: 'localhost-ok',
redirect_uris: ['http://localhost:3000/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
});
expect(result.client_id).toStartWith('gbrain_cl_');
});
test('https:// is allowed', async () => {
const result = await provider.clientsStore.registerClient!({
client_name: 'https-ok',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
});
expect(result.client_id).toStartWith('gbrain_cl_');
});
test('plaintext http:// (non-loopback) is rejected', async () => {
await expect(
provider.clientsStore.registerClient!({
client_name: 'http-rejected',
redirect_uris: ['http://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
}),
).rejects.toThrow(/https/);
});
test('non-URL string is rejected', async () => {
await expect(
provider.clientsStore.registerClient!({
client_name: 'garbage',
redirect_uris: ['not-a-url'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
}),
).rejects.toThrow();
});
// pgArray escape regression: an element containing a comma must be stored
// as ONE element, not parsed by Postgres as TWO. Without the fix, the
// comma would smuggle a second redirect_uri into the registered list.
test('redirect_uri with embedded comma stored as single element', async () => {
// Use a localhost URI with comma in the path so it passes HTTPS validation.
const trickyUri = 'http://localhost:3000/cb,evil';
const result = await provider.clientsStore.registerClient!({
client_name: 'comma-test',
redirect_uris: [trickyUri],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
});
// Read back from the DB and confirm exactly one element.
const stored = await provider.clientsStore.getClient(result.client_id);
expect(stored).toBeDefined();
expect(stored!.redirect_uris).toHaveLength(1);
expect(stored!.redirect_uris[0]).toBe(trickyUri);
});
});
// ---------------------------------------------------------------------------
// F1 / F4 — Wrong-client cross-tenant attempts
// ---------------------------------------------------------------------------
//
// The atomic client_id binding lives in the DELETE WHERE clause for auth
// codes (exchange + challenge), refresh tokens (rotate), and revocations.
// Without it, any authenticated client that knew/guessed another client's
// hash could (a) consume the code/refresh on the wrong-client path,
// burning it for the legitimate client, or (b) revoke another client's
// tokens. These tests pin the negative invariant — wrong client fails —
// AND the positive invariant — owner still succeeds atomically afterward.
describe('F1/F4 cross-client isolation', () => {
test('wrong client cannot consume another client authorization code', async () => {
const { clientId: ownerId } = await provider.registerClientManual(
'authcode-owner-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const { clientId: attackerId } = await provider.registerClientManual(
'authcode-attacker-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const owner = (await provider.clientsStore.getClient(ownerId))!;
const attacker = (await provider.clientsStore.getClient(attackerId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(owner, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
// Attacker holding the same code MUST be rejected.
await expect(provider.exchangeAuthorizationCode(attacker, code)).rejects.toThrow();
// The atomic predicate's payoff: the legitimate owner can STILL redeem
// the code afterward. Without it, the attacker would have burned the
// row in the DELETE and the owner's redemption would 404.
const tokens = await provider.exchangeAuthorizationCode(owner, code);
expect(tokens.access_token).toStartWith('gbrain_at_');
});
test('wrong client cannot read another client PKCE challenge', async () => {
const { clientId: ownerId } = await provider.registerClientManual(
'challenge-owner-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const { clientId: attackerId } = await provider.registerClientManual(
'challenge-attacker-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const owner = (await provider.clientsStore.getClient(ownerId))!;
const attacker = (await provider.clientsStore.getClient(attackerId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(owner, {
codeChallenge: 'owner-challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
await expect(provider.challengeForAuthorizationCode!(attacker, code)).rejects.toThrow();
await expect(provider.challengeForAuthorizationCode!(owner, code)).resolves.toBe('owner-challenge');
});
test('wrong client cannot revoke another client token', async () => {
const { clientId: ownerId, clientSecret: ownerSecret } = await provider.registerClientManual(
'revoke-owner-test', ['client_credentials'], 'read',
);
const { clientId: attackerId } = await provider.registerClientManual(
'revoke-attacker-test', ['client_credentials'], 'read',
);
const tokens = await provider.exchangeClientCredentials(ownerId, ownerSecret, 'read');
const attacker = (await provider.clientsStore.getClient(attackerId))!;
// Attacker tries to revoke owner's token. revokeToken returns void
// (silent on no-op), so we assert the token still verifies after.
await provider.revokeToken!(attacker, { token: tokens.access_token });
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(authInfo.clientId).toBe(ownerId);
});
});
// ---------------------------------------------------------------------------
// F2 + F3 — Refresh-token cross-client isolation + scope subset
// ---------------------------------------------------------------------------
describe('F2/F3 refresh hardening', () => {
test('wrong client cannot burn another client refresh token', async () => {
const { clientId: ownerId } = await provider.registerClientManual(
'refresh-owner-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const { clientId: attackerId } = await provider.registerClientManual(
'refresh-attacker-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const owner = (await provider.clientsStore.getClient(ownerId))!;
const attacker = (await provider.clientsStore.getClient(attackerId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(owner, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(owner, code);
// Attacker rejected.
await expect(provider.exchangeRefreshToken(attacker, tokens.refresh_token!)).rejects.toThrow();
// Owner still redeems atomically — the row was not burned by the
// attacker's attempt.
const rotated = await provider.exchangeRefreshToken(owner, tokens.refresh_token!);
expect(rotated.access_token).toStartWith('gbrain_at_');
expect(rotated.refresh_token).toBeDefined();
expect(rotated.refresh_token).not.toBe(tokens.refresh_token);
});
test('refresh cannot request scopes outside the original grant (F3)', async () => {
// Client allowed scopes 'read write', but the user only authorized 'read'.
// The refresh token row carries the granted scope, NOT the client's
// currently-allowed scopes (codex C9). Requesting 'write' on refresh
// must fail even though the client could mint a fresh write-scoped
// token via a new authorize round trip.
const { clientId } = await provider.registerClientManual(
'refresh-scope-test', ['authorization_code'], 'read write',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
// Attempt to escalate to write — must reject.
await expect(
provider.exchangeRefreshToken(client, tokens.refresh_token!, ['read', 'write']),
).rejects.toThrow(/scope/i);
});
// T1 (eng-review): admin grant must be refreshable down to sources_admin
// via hasScope. Pre-v0.28 the F3 check was exact-string-match, so an
// admin grant could not refresh down to sources_admin even though admin
// implies it. gstack /setup-gbrain Path 4 needs this to work.
test('admin grant CAN refresh down to sources_admin (hasScope hierarchy)', async () => {
const { clientId } = await provider.registerClientManual(
'admin-down-test', ['authorization_code'], 'admin',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['admin'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
// Refresh requesting only sources_admin — admin implies it, so this
// must succeed and the new token must carry only the requested subset.
const rotated = await provider.exchangeRefreshToken(
client, tokens.refresh_token!, ['sources_admin'],
);
expect(rotated.access_token).toBeDefined();
expect(rotated.scope).toBe('sources_admin');
// The original refresh token must be dead (single-use rotation).
await expect(
provider.exchangeRefreshToken(client, tokens.refresh_token!),
).rejects.toThrow();
// Note: rotated.refresh_token's grant is now sources_admin, not admin.
// Refreshing it up to users_admin would correctly fail (sibling
// non-implication) — that constraint is exercised in the F3 sibling
// test below. To prove "admin implies users_admin too" we'd need a
// fresh authorize round trip, which the existing F2 hardening tests
// already cover. One direction at a time.
});
test('admin grant CAN refresh down to users_admin (different axis)', async () => {
const { clientId } = await provider.registerClientManual(
'admin-down-users-test', ['authorization_code'], 'admin',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['admin'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
const rotated = await provider.exchangeRefreshToken(
client, tokens.refresh_token!, ['users_admin'],
);
expect(rotated.scope).toBe('users_admin');
});
// T1 sibling: write grant cannot refresh up to sources_admin (different axis)
test('write grant CANNOT refresh to sources_admin (sibling non-implication)', async () => {
const { clientId } = await provider.registerClientManual(
'write-not-sources-admin-test', ['authorization_code'], 'write',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['write'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
await expect(
provider.exchangeRefreshToken(client, tokens.refresh_token!, ['sources_admin']),
).rejects.toThrow(/scope/i);
});
});
// ---------------------------------------------------------------------------
// v0.28 — ALLOWED_SCOPES allowlist at registration time
// ---------------------------------------------------------------------------
describe('v0.28 ALLOWED_SCOPES allowlist', () => {
test('registerClientManual rejects unknown scope strings', async () => {
await expect(
provider.registerClientManual('bad-scope', ['client_credentials'], 'read flying-unicorn'),
).rejects.toThrow(/Unknown scope/);
});
test('registerClientManual accepts every canonical scope', async () => {
for (const scope of ['read', 'write', 'admin', 'sources_admin', 'users_admin']) {
const { clientId } = await provider.registerClientManual(
`accept-${scope}`, ['client_credentials'], scope,
);
const client = await provider.clientsStore.getClient(clientId);
expect(client?.scope).toBe(scope);
}
});
test('registerClient (DCR) rejects unknown scope strings', async () => {
await expect(
provider.clientsStore.registerClient!({
client_name: 'dcr-bad-scope',
redirect_uris: ['https://example.com/cb'],
grant_types: ['authorization_code'],
scope: 'read bogus_scope',
token_endpoint_auth_method: 'client_secret_post',
} as any),
).rejects.toThrow(/Unknown scope/);
});
});
// ---------------------------------------------------------------------------
// F5 — fail-loud column probes (was: bare catch{})
// ---------------------------------------------------------------------------
describe('F5 verifyAccessToken / client_credentials column probes', () => {
test('non-schema SQL failures are not swallowed by client credentials soft-delete probe', async () => {
// Synthesize a non-schema error (SQLSTATE 57P01 = admin_shutdown) and
// make sure the catch block re-throws instead of silently treating
// the client as not-revoked. Without the predicate this throw used to
// disappear into the void.
const sqlFailure = Object.assign(new Error('database session failed'), { code: '57P01' });
const fakeSql = async (strings: TemplateStringsArray): Promise<Record<string, unknown>[]> => {
const query = strings.join('$');
if (query.includes('SELECT client_id, client_secret_hash')) {
return [{
client_id: 'gbrain_cl_fake',
client_secret_hash: hashToken('secret'),
client_name: 'fake',
redirect_uris: [],
grant_types: ['client_credentials'],
scope: 'read',
client_id_issued_at: 1,
}];
}
if (query.includes('SELECT deleted_at')) throw sqlFailure;
return [];
};
const failingProvider = new GBrainOAuthProvider({ sql: fakeSql as any });
await expect(
failingProvider.exchangeClientCredentials('gbrain_cl_fake', 'secret', 'read'),
).rejects.toThrow('database session failed');
});
});
// ---------------------------------------------------------------------------
// F6 — sweepExpiredTokens returns a meaningful count across both engines
// ---------------------------------------------------------------------------
describe('F6 sweepExpiredTokens count', () => {
test('returns count > 0 after deleting expired rows', async () => {
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
const t1 = hashToken(generateToken('sweep_count_'));
const t2 = hashToken(generateToken('sweep_count_'));
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES (${t1}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${1})`;
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES (${t2}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${2})`;
const swept = await provider.sweepExpiredTokens();
// Pre-fix: returned 0 on PGLite/postgres.js even when rows were deleted
// because (result as any).count was unset on at least one path. With
// RETURNING 1 + result.length, the actual row count flows back.
expect(swept).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// F7c — auth code redirect_uri validated on /token (RFC 6749 §4.1.3)
// ---------------------------------------------------------------------------
describe('F7c redirect_uri binding on auth code exchange', () => {
test('matching redirect_uri succeeds', async () => {
const { clientId } = await provider.registerClientManual(
'redir-match-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(
client, code, undefined, 'http://localhost:3000/callback',
);
expect(tokens.access_token).toStartWith('gbrain_at_');
});
test('mismatched redirect_uri rejects', async () => {
const { clientId } = await provider.registerClientManual(
'redir-mismatch-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
// Attacker submitting the auth code with a different redirect_uri (e.g.,
// an attacker-controlled callback URL) MUST be rejected. RFC 6749 §4.1.3.
await expect(
provider.exchangeAuthorizationCode(
client, code, undefined, 'https://attacker.example/cb',
),
).rejects.toThrow();
});
test('empty-string redirect_uri does NOT bypass the binding', async () => {
// D15 / adversarial-review fix: `redirectUri ? ...` would treat empty string
// as falsy and silently fall through to the no-redirect-uri branch,
// letting an attacker submit `redirect_uri=""` to bypass the predicate.
// The fix uses `redirectUri !== undefined`. This test asserts the bypass
// is closed: an empty-string redirect_uri must reject (zero-row DELETE
// since stored value is the original non-empty URI), not slip through.
const { clientId } = await provider.registerClientManual(
'redir-empty-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
await expect(
provider.exchangeAuthorizationCode(client, code, undefined, ''),
).rejects.toThrow();
});
test('omitted redirect_uri (back-compat) still succeeds', async () => {
// Existing callers that don't pass redirectUri keep working — the
// predicate only fires when redirectUri is provided. This protects
// against breaking SDK consumers that haven't adopted the parameter
// yet, while still hardening the path for those that have.
const { clientId } = await provider.registerClientManual(
'redir-omitted-test', ['authorization_code'], 'read',
['http://localhost:3000/callback'],
);
const client = (await provider.clientsStore.getClient(clientId))!;
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'challenge',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(client, code);
expect(tokens.access_token).toStartWith('gbrain_at_');
});
});
// ---------------------------------------------------------------------------
// F12 — DCR disable via constructor option (cleanup, not security)
// ---------------------------------------------------------------------------
describe('F12 dcrDisabled constructor option', () => {
test('clientsStore omits registerClient when dcrDisabled=true', () => {
const dcrOff = new GBrainOAuthProvider({ sql, dcrDisabled: true });
const store = dcrOff.clientsStore;
expect(typeof store.getClient).toBe('function');
// SDK's mcpAuthRouter checks for registerClient before wiring up the
// /register endpoint. Absence of the method == DCR endpoint not exposed.
expect((store as any).registerClient).toBeUndefined();
});
test('clientsStore exposes registerClient when dcrDisabled is false/unset', () => {
const dcrOn = new GBrainOAuthProvider({ sql });
expect(typeof dcrOn.clientsStore.registerClient).toBe('function');
});
test('registerClientManual still works on dcrDisabled providers (CLI path)', async () => {
// The CLI code path uses registerClientManual, which is independent of
// the DCR /register endpoint. dcrDisabled must NOT break it.
const dcrOff = new GBrainOAuthProvider({ sql, dcrDisabled: true });
const result = await dcrOff.registerClientManual(
'dcr-disabled-cli-test', ['client_credentials'], 'read',
);
expect(result.clientId).toStartWith('gbrain_cl_');
expect(result.clientSecret).toStartWith('gbrain_cs_');
});
});
// ---------------------------------------------------------------------------
// v0.34.1 (#909) — PKCE public-client DCR (RFC 7591 §3.2.1)
// ---------------------------------------------------------------------------
//
// Per RFC 7591 §3.2.1, when a DCR client declares
// `token_endpoint_auth_method: "none"` (PKCE-only public clients like Claude
// Code, Cursor), the authorization server MUST NOT issue a client_secret.
// Pre-fix, unconditional secret generation made the MCP SDK's clientAuth
// middleware reject valid public-client flows on /token.
describe('PKCE DCR public-client gate (#909)', () => {
test("registerClient with token_endpoint_auth_method='none' omits client_secret", async () => {
const result = await provider.clientsStore.registerClient!({
client_name: 'public-pkce-client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'none',
});
expect(result.client_id).toStartWith('gbrain_cl_');
// RFC 7591 §3.2.1: public clients get NO client_secret in the response.
expect(result.client_secret).toBeUndefined();
expect(result.token_endpoint_auth_method).toBe('none');
});
test('default auth_method (omitted) still issues a client_secret', async () => {
// Regression guard: confidential clients (the existing default) must
// keep their secret-issuing behavior unchanged.
const result = await provider.clientsStore.registerClient!({
client_name: 'confidential-default',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
// token_endpoint_auth_method omitted; falls back to 'client_secret_post'
});
expect(result.client_id).toStartWith('gbrain_cl_');
expect(result.client_secret).toStartWith('gbrain_cs_');
});
test('explicit client_secret_post still issues a client_secret', async () => {
const result = await provider.clientsStore.registerClient!({
client_name: 'confidential-explicit',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
});
expect(result.client_id).toStartWith('gbrain_cl_');
expect(result.client_secret).toStartWith('gbrain_cs_');
});
test('getClient on a public client returns client_secret=undefined (NULL normalized)', async () => {
// The SDK's clientAuth middleware checks `client.client_secret === undefined`
// (not `=== null`) to decide whether to enforce secret comparison on /token.
// Without normalization, Postgres NULL would reach the SDK as JS null and
// the secret check would mis-fire on every public client.
const reg = await provider.clientsStore.registerClient!({
client_name: 'public-getclient-norm',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'none',
});
const stored = await provider.clientsStore.getClient(reg.client_id);
expect(stored).toBeDefined();
expect(stored!.client_secret).toBeUndefined();
expect(stored!.token_endpoint_auth_method).toBe('none');
});
test('PKCE flow end-to-end: public client /authorize then /token, no secret needed', async () => {
// Full F7 regression #15: public client completes auth_code → token
// exchange without ever presenting a client_secret.
const reg = await provider.clientsStore.registerClient!({
client_name: 'pkce-roundtrip',
redirect_uris: ['http://localhost:3000/callback'],
grant_types: ['authorization_code'],
scope: 'read',
token_endpoint_auth_method: 'none',
});
// Re-fetch via getClient to mirror what the SDK middleware sees.
const client = (await provider.clientsStore.getClient(reg.client_id))!;
expect(client.client_secret).toBeUndefined();
let redirectUrl = '';
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
await provider.authorize(client, {
codeChallenge: 'test-challenge-value',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read'],
}, mockRes);
const code = new URL(redirectUrl).searchParams.get('code')!;
expect(code).toMatch(/^gbrain_code_/);
// Exchange the code — public client; no secret on the wire.
const tokens = await provider.exchangeAuthorizationCode(client, code);
expect(tokens.access_token).toStartWith('gbrain_at_');
// SDK normalizes token_type per RFC 6750 §6.1.1 (case-insensitive);
// implementations may emit "bearer" lowercase.
expect(String(tokens.token_type).toLowerCase()).toBe('bearer');
});
});