Files
gbrain/test/oauth.test.ts
Garry TanandClaude Fable 5 130d321d23 v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope (#3902)
* fix(schema): bootstrap timeline_entries.event_page_id forward reference — un-wedge pre-v121 upgrades (#2626 #2594 #2579 #2537 #2536)

v0.42.56.0 (Chronicle, migration v121) added timeline_entries.event_page_id
and two partial indexes in the embedded schema blobs without extending
applyForwardReferenceBootstrap — any brain whose timeline_entries predates
v121 wedged initSchema at blob replay ("column event_page_id does not
exist") before runMigrations could apply v121, with no in-band recovery.

- Add the timeline_entries.event_page_id probe + column-only ALTER to
  applyForwardReferenceBootstrap in BOTH engines; FK + partial indexes land
  via the idempotent v121 / blob replay afterwards. Stays in the always-run
  bootstrap (never a migration hook — those skip oddly-stamped brains).
- REQUIRED_BOOTSTRAP_COVERAGE entry + strip blocks in both runtime tests.
- e2e: pre-v121 rewind → full initSchema converges (indexes re-created);
  wedged-brain recovery — a brain that already FAILED the upgrade attempt
  converges on retry with full final shape (column + FK + both partial
  indexes) and no ledger residue.

Absorbs PR #2548 (@chetan-guevara) and the e2e test from PR #2623
(@colinagent) — thank you both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): coverage guard cross-references migration-added columns — close the scan hole that shipped the v121 wedge

The A2 static check treated a column as covered when the current CREATE
TABLE body declared it. But a column that is BOTH in the blob's CREATE
TABLE AND added by a migration is a forward reference by definition — on
pre-existing tables CREATE TABLE IF NOT EXISTS no-ops and the blob's
CREATE INDEX crashes initSchema before runMigrations can help. That mask
is exactly how timeline_entries.event_page_id passed the guard while
wedging every pre-v121 brain.

- buildIndexRefCoveragePredicate: migration-added columns (from
  extractAddedColumnsFromMigrations over the MIGRATIONS array) require a
  bootstrap ALTER; CREATE TABLE presence no longer counts for them.
- Unit test pins the v121 regression shape red/green with synthetic inputs;
  the A2 test pins the incident triple directly (migration-added +
  blob-indexed + bootstrap-covered).
- The strengthened predicate immediately surfaced two more latent wedges of
  the same class: minion_jobs.timeout_at + minion_jobs.idempotency_key
  (migration v7, blob-indexed, unprobed). Added probes in both engines +
  coverage entries + runtime strip blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): serve --http hides the generated admin token from non-TTY output by default (#2624)

Generated admin bootstrap tokens printed into container/log-aggregator
stdout on every headless start. shouldSuppressBootstrapPrint now defaults
to hidden unless stderr is an interactive TTY; env-sourced tokens are
never printed; --print-admin-token is the explicit escape hatch for
capturing the value on a trusted non-TTY start; --suppress-bootstrap-token
still overrides everything. Unit-tested across all five postures.

Absorbs PR #2625 (@irresi) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): legacy bearer tokens honor permissions.takes_holders over serve --http (#2529)

GBrainOAuthProvider.verifyAccessToken never returned takesHoldersAllowList,
so the serve --http dispatch site always fell back to ['world'] — remote
MCP callers with an operator-configured takes_holders grant saw only
public takes. The legacy branch now extracts permissions.takes_holders
exactly like src/mcp/http-transport.ts (fail-safe ['world'] default,
non-string entries dropped, malformed permissions JSON fails closed
without throwing), and AuthInfo carries the field as a typed contract.

OAuth-registered clients have no takes_holders storage on oauth_clients;
that lane is design work tracked in TODOS (column migration + DCR/
registration surface), not part of this hotfix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): get_chunks honors the federated source grant + stops shipping embedding vectors (#2555, getChunks half of #2544)

The get_chunks op still used the pre-#2200 scalar pattern
(ctx.sourceId ? {sourceId} : {}) and engine.getChunks had no sourceIds[]
support — a federated client that could read a page via get_page got []
from get_chunks. The op now routes through sourceScopeOpts (canonical
ladder: federated array > scalar floor > nothing) and both engines gain
the getPage-style sourceIds[] precedence branch; the unset-opts 'default'
floor is preserved for local callers (importCodeFile contract).

While in the function: SELECT cc.* pulled every embedding vector over the
wire per chunk only for rowToChunk to discard them — replaced with the
explicit non-vector column list in both engines (the getChunks half of
#2544; the per-put_page getAllSlugs half is tracked separately).

getChunksWithEmbeddings stays scalar-only by design (engine-internal,
zero remote-reachable callers — documented at the interface).

Tests: op-level federated repro + isolation + default-floor bleed guard
(PGLite), engine precedence + Chunk-shape pin, and a DATABASE_URL-gated
engine-parity test covering all three scope shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): /admin/api/register-client accepts source + federatedRead bindings (#2143 enabler)

The HTTP register endpoint hardcoded source_id='default' and
federated_read=undefined — only the CLI could mint a client bound to a
non-default source, so HTTP-registered MCP clients wrote into 'default'
regardless of intent. The endpoint now accepts optional source /
federatedRead body fields, validated via assertValidSourceId with a
structured 400 on bad input; omitting both preserves the historical
default. The admin-UI form layer is a tracked follow-up.

Absorbs PR #2016 — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scope,calibration): think reads over MCP; source-scoped takes reads; calibration CLI reachability + model resolution (#2078-class #2451)

- think op → scope:'read' for OAuth/MCP clients: the handler already forces
  save/take off for remote callers before persistence, so a read-scoped
  token can think without a write grant; local CLI persistence unchanged.
  Scope-annotation test carries an explicit remote-gated allowlist.
- takes_list / takes_search / takes_scorecard / takes_calibration route
  through sourceScopeOpts (the #2200 class on the takes read lane) with
  engine support in BOTH engines + tests.
- 'calibration' added to CLI_ONLY (the command was registered but
  unreachable — dispatch-gap class) and calibration_profile/voice-gate
  resolve models through the canonical gateway tier resolver instead of
  bare ids that parseModelId rejects.
- BigInt-safe local-op output normalization (bigintToStringReplacer,
  postgres.js wire parity) — first half of the #2450 fix; the
  formatResult default case lands with the cli-output commit.

Absorbs PR #2598 (@colinagent) and PR #2452 (@spinsirr) — thank you both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): output correctness — BigInt-safe rendering, search --json, files bigint, and 5 unreachable commands (#2450 #2527 #2042 #2035-class)

- normalizeLocalResult wraps the local-op output round-trip with the
  bigint→string replacer (postgres.js wire parity; a bare stringify THROWS
  on BIGSERIAL keys); formatResult's default renderer gets the same
  replacer so nothing upstream can crash it.
- search/query --json: CLI-local formatter flag threaded through the shared
  formatter — stdout is a parseable result array, never human text on the
  --json path (the #2042 residual).
- file_list normalizes size_bytes (Postgres BIGINT → Number) so MCP
  serialization and the CLI KB math survive; null preserved.
- NEW dispatch-gap guard: every handleCliOnly top-level case label must be
  reachable via CLI_ONLY. It immediately caught FIVE live unreachable
  commands: pages, backfill, reconcile-links, notability-eval (added to
  CLI_ONLY), and the documented 'gbrain search modes|stats|tune' dashboards
  (pre-fix, 'search modes' silently keyword-searched the word "modes") —
  now routed via a pre-dispatch subcommand gate. 'whoknows' stays on its
  op-alias route (collision guard); tracked with PR #2509.

Absorbs PR #2494 and PR #2531 (@javieraldape) and adapts PR #472
(@vinsew) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): updateSourceConfig survives mixed-array config rows + repair/doctor cover the subagent jsonb columns (#2251)

The array-coercion branch called jsonb_each(elem) bare — a mixed array
(e.g. '["x", {"last_full_cycle_at": ...}]') threw 'cannot call
jsonb_each on a non-object' DURING row production, permanently failing
every subsequent updateSourceConfig (last_full_cycle_at could never be
written again). Non-object elements are now neutralized inline via a
CASE-guarded jsonb_each; the row self-heals to a flat object on the next
write, object elements' keys recovered. Pinned ungated on PGLite (real
Postgres semantics) and via a DATABASE_URL-gated e2e on the real engine.

repair-jsonb + doctor's jsonb_integrity check extend from 5 to 8 columns
(subagent_messages.content_blocks, subagent_tool_executions.input/output
— historical damage rows from the pre-v0.42.53.0 positional double-encode;
the write paths themselves were fixed in #2375) with a to_regclass skip
for brains predating those tables.

Adapts the repair/doctor extension from PR #597 (@vinsew) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): dry-run honesty — strict unknown-flag rejection CLI-wide + unify-types worker defaults to dry-run (#2185 #1575)

#2185: 'gbrain init --migrate-only --dry-run' applied REAL migrations —
flags are read ad hoc (args.includes) so anything a handler doesn't look
for was silently ignored, including intent-bearing safety flags. The CLI
now validates every flag pre-dispatch and pre-engine:

- Op commands validate against the operation contract (op.params +
  CLI-local --json/--explain), mirroring parseOpArgs so flag values that
  begin with '--' are never misread. parseOpArgs also gains the
  --key=value inline form (previously parsed as a junk key that consumed
  the NEXT token).
- CLI_ONLY commands validate against a GENERATED per-command registry
  (scripts/generate-flag-registry.ts scans each command's case block +
  imported modules + one level of relative imports; deliberately
  over-inclusive so a missed flag can't break a working invocation).
  Committed as src/core/cli-flag-registry.generated.ts;
  'bun run build:flag-registry' regenerates.
- Passthrough by construction: everything after '--', plus call / config /
  'jobs submit' payloads (handler-defined params are their contract).
- Guards: sweep test (every command × nonsense flag → error), acceptance
  tests (real flags, --no- negation, = form, -- passthrough), drift guard
  (every CLI_ONLY member has an entry), freshness guard (committed
  registry == fresh generator run), subprocess smokes incl. the literal
  #2185 repro failing loud with zero engine work.

BREAKING: scripts passing stray flags now fail loud with
"Unknown flag --x for 'gbrain <cmd>'" — that is the point.

#1575: the unify-types worker registration passed apply ?? true while the
handler documents 'Default false (dry-run)' — the canonical operator
invocation destructively retyped 25K+ pages by default. Now ?? false with
a structural test; explicit --params '{"apply":true}' is the only way to
mutate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(todos): file fix-wave 1 follow-ups (OAuth takes_holders design, parseFlags end-state, whoknows routing, #2544 half, #1558 UI, #2536 diagnostics)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): memory-safe unit runner — adaptive concurrency + serial OOM rescue pass

A default run (4 shards × 4 intra-shard files) holds up to 16 concurrent
PGLite WASM instances (~1.5GB each). With sibling Conductor workspaces
running their own suites, PGLite connect failed with 'Out of memory'
across every shard at once — 369 phantom test failures on a healthy
branch, indistinguishable from real breakage at a glance.

Two default-on layers in scripts/run-unit-parallel.sh:
1. Memory-aware sizing: total concurrency is capped to available memory
   (vm_stat on macOS, MemAvailable on Linux) at GBRAIN_TEST_MEM_PER_FILE_MB
   (default 1536) per concurrent file, shedding shards before intra-shard
   width. Quiet machines are unaffected (banner: mem-ok); pressured ones
   degrade instead of OOMing (banner: mem-adapted AxB→CxD).
2. Serial OOM rescue: failures whose shard log carries the WASM
   out-of-memory signature are re-run at --max-concurrency 1 after the
   fan-out drains. Phantoms pass serially → run goes green with an
   oom_rescued note and the failure blocks marked superseded; real
   failures fail again and stay red. Plain assertion failures never match
   the signature and never enter the rescue lane (existing exit-code and
   failure-log contract tests unchanged).

Escape hatches: GBRAIN_TEST_NO_MEM_ADAPT=1, GBRAIN_TEST_NO_OOM_FALLBACK=1.
Tests: OOM-once fixture rescued to exit 0; kill-switch stays red; banner
advertises the sizing verdict. Documented in docs/TESTING.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test,cli): merge-seam repairs — shard timeout for the tripled suite, init flag-error contract, think scope exemption

Three post-merge repairs surfaced by the first full-suite run:
- Shard timeout 1500s -> 3000s: the suite roughly tripled since the cap was
  sized (~3900 -> 11k+ tests; PGLite inits replay 120 migrations, was 92).
  Two shards were killed mid-progress at 1500s.
- The #2185 pre-dispatch validator now emits the same error contract as
  init.ts's in-handler check it preempts: lowercase 'unknown flag' on
  stderr + structured {status:'error', reason:'invalid_flag'} on stdout
  for --json callers (pinned by test/init-migrate-only.test.ts).
- test/operations-trust-boundary.test.ts gets the same documented
  remote-gated allowlist for think's read scope (#2598) that
  test/oauth.test.ts already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): rescue lane also covers externally-killed shards (sibling-workspace pkill / memory jetsam)

Second phantom class observed on a multi-workspace Conductor machine:
3 shards SIGTERM'd + 1 SIGKILL'd at ~700s under a 3000s cap, all
mid-progress — an external killer, not a wedge. The dead shards then
poisoned the serial pass (lock/state residue → 18 more phantoms), and
every one of the 18 passed standalone.

The runner now stamps per-shard start/end epochs; a shard dying on
143/137 before 80% of SHARD_TIMEOUT is classified externally-killed and
its file list joins the serial rescue queue (real wedges die AT the cap
and stay red). Serial-pass failures that occur while any shard was
externally killed are treated as suspect residue and rescued too.
Structural tests pin the detector, threshold, and routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): keep tokenEndpointAuthMethod terminal in the register-client destructure (PKCE structural contract)

The #2016 absorb appended source/federatedRead after
tokenEndpointAuthMethod; test/fix-wave-structural.test.ts pins
tokenEndpointAuthMethod as the final destructured field (v0.36.1.x #1077
PKCE regression contract). The added fields move into the regex's
optional-middle slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): re-apply the #2598 remote-gated scope exemption to master's oauth scope-annotation test

Taking master's v0.42.74.0 oauth.test.ts (its #2529 implementation) dropped
the think read-scope allowlist that PR #2598 carries; re-applied to match
test/operations-trust-boundary.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): pre-landing review batch — CLI-local flag acceptance, json= coherence, re-landed getChunks trim, payload-aware jsonb repair, rescue-lane tightening

Ship Step 9 findings (checklist pass + 4 specialists), all verified before fixing:
- CRITICAL: the #2185 validator rejected --source/--dry-run on op commands
  (makeContext CLI-locals consumed outside the op contract) — 'gbrain search
  "x" --source y' exited 1. Exempted with parser-mirroring value consumption
  + unit/subprocess tests incl. global-flag acceptance.
- CRITICAL: --json=<v> diverged between validator (accepted) and parseOpArgs
  (junk-key path consumed the NEXT token, corrupting positionals). Parser now
  handles --json=true|false; =-forms of bare-only CLI-locals reject loud.
  parseOpArgs inline-= suite added (regression rule).
- CRITICAL: the master merge silently restored SELECT cc.* in both engines'
  getChunks while docs claimed the #2544 trim. Re-landed the explicit
  non-vector column list + a source-level structural pin so a merge can't
  silently undo it again.
- repair-jsonb/doctor: the subagent columns legitimately hold jsonb string
  scalars (persistToolExec binds pre-serialized strings) — unconditional
  unwrap would abort the repair run or corrupt legit values. jsonPayloadOnly
  predicate (JSON-container content only) on those 3 targets, mirrored in
  doctor + parameterized to_regclass + behavioral test (damage flagged,
  legit string ignored, absent table skipped).
- runner rescue-lane tightening: serial failures rescue-eligible only with
  their own OOM signature or after an external shard kill (residue), never
  because a sibling shard OOM'd — flaky serial tests stay red. Rescue passes
  no longer double-count into TOTAL_PASS; shard timeout scales when
  mem-adaptation sheds shards; negative-path tests (mixed run stays red,
  deterministic OOM-signature failure stays red).
- fail-closed remote spelling at 2 forward sites (ctx.remote !== false per
  the CLAUDE.md invariant); registry generator drops template-literal flag
  prefixes; real-PG e2es for the v121 + minion_jobs wedge classes; stale
  comments corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate flag registry after master merge (#3864 added extract help flags)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): red-team batch — dispatch-order validation, real --dry-run boolean, rescue-lane timeout/isolation/cap, parseable-payload repair gate

Red-team pass (post-specialist) findings, all verified before fixing:
- CRITICAL: validateCommandFlags checked the op lane before CLI_ONLY while
  dispatch runs CLI_ONLY first — dual-lane commands (think/salience/
  anomalies) were validated against the WRONG contract, rejecting documented
  invocations ('salience --kind entity'). Lane order now mirrors dispatch.
- CRITICAL: --dry-run was blessed as legal on op commands but parseOpArgs
  never SET it (trailing → nothing → ctx.dryRun false → the REAL destructive
  action ran; leading → consumed the next token). Now a CLI-local boolean
  exactly like --json, with --dry-run=false support and regression tests.
- CRITICAL: the rescue lane ran bun test WITHOUT --timeout=60000 (bun default
  5s) — PGLite phantoms re-failed on timeout and were mislabeled 'confirmed
  real'. Both rescue invocations now mirror the shard flags; serial files
  re-run one process per file (run-serial-tests.sh isolation contract);
  rescue wallclock capped at 2x the shard timeout.
- CRITICAL: the jsonPayloadOnly probe matched container-LOOKING invalid JSON
  ('[INFO] fetch complete') whose repair cast would throw and abort the run
  mid-loop. Predicate now gates on pg_input_is_valid (PG16+, same floor as
  the existing IS JSON usage) + per-target catch records and continues;
  doctor mirrors; behavioral test covers the lookalike row.
- Registry generator bounded at handleCliOnly's closing brace (the LAST case
  block absorbed ~100 junk flags from the rest of cli.ts, neutering strict
  validation for it); uppercase flag typos reject loudly in both lanes
  (handlers are lowercase-sensitive); --json=true spelling gets the
  structured invalid_flag envelope; get_chunks __all__ narrowing filed as a
  Wave 3 TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engines): getChunks trimmed SELECT must carry modality (codex P1-B)

The #2544 egress trim replaced SELECT cc.* with an explicit column list but
omitted cc.modality — every rowToChunk field except the vector must survive
the trim, or the embed round-trip (getChunks -> upsertChunks) rewrites image
chunks as text. Both engines; the structural pin now iterates the full
rowToChunk field list instead of spot-checking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): safety flags require consumption evidence in the flag registry (codex P1-A)

upgrade.ts prints a help hint naming another command's --dry-run; that
literal is depth-0 text for post-upgrade, so the generator allowlisted
--dry-run there — recreating the exact #2185 repro this wave kills
(post-upgrade --dry-run accepted, ignored, migrations run for real).
Safety flags now need a tight-quoted standalone literal (an args read
like has('--dry-run')) before the registry grants them; prose bleed
embeds the flag inside a longer string and never qualifies. Regenerated
registry drops --dry-run from post-upgrade, keeps genuine consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope

Version bump + CHANGELOG for the fix wave: CLI-wide unknown-flag
rejection with a generated per-command registry (#2185, #1575 class),
minion_jobs bootstrap probes + migration-aware coverage guard (v121
wedge class), get_chunks federated scope + egress trim (#2555, half of
#2544), think read-scope over MCP (#2598), register-client source
bindings (#2016, #2143 enabler), repair-jsonb/doctor subagent columns
with a parse-validated damage predicate, memory-safe unit-test runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v0.42.76.0

CONTRIBUTING.md: test-runner claims match the memory-safe 4-shard default
(was 8-shard) and the CLI-only command recipe now includes the
build:flag-registry regen step. KEY_FILES.md: repair-jsonb entry updated
to the 8-column parse-gated current state; new entries for the strict
flag-validation subsystem and src/core/source-id.ts; operations/engine/
serve-http entries updated for get_chunks scope ladder + trimmed SELECT
and the register-client HTTP source bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply doc-review fixes for v0.42.76.0

Cross-model doc review caught stragglers: docs/TESTING.md still said
8-shard in the file taxonomy, carried a two-generations-stale shard
timeout default (600s -> 3000s), and didn't name the new
run-unit-parallel regression test or the remaining runner knobs;
CONTRIBUTING.md's fast-loop file count predated the tripled suite
(92+ -> 1000+); test-count claims unified at 3700+; the
CLIENT_FENCED_WRITE_OPS comment in operations.ts still described
think as scope write; KEY_FILES names the exported findUnknownFlag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): rescue lane survives CI — strip ::group:: prefixes, satisfy the bun-test-timeout guard

Two CI-only breaks from the master merge: (1) under GITHUB_ACTIONS the
shard wraps file sections as ::group::path.test.ts, so the rescue pass
extracted literal ::group:: non-paths that matched zero test files —
failing_files_in_log now strips the prefix; (2) master's new
check-bun-test-timeout guard greps for bare 'bun test' and tripped on
run_rescue's comment text (the invocations themselves carry
--timeout=60000) — comment reworded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): shard-mechanics tests disable mem-adaptation — CI's 7GB runner collapsed explicit 2 shards to 1

The runner deliberately adapts even explicit --shards to available memory
(GBRAIN_TEST_NO_MEM_ADAPT=1 is the escape hatch); on GitHub's ~7GB runners
that collapsed the tests' 2-shard sandbox runs to 1 shard, breaking every
'shard 1/2:' expectation while passing locally. The tests pin shard
MECHANICS with tiny synthetic files, so they now set the escape hatch;
the one test that checks the mem banner overrides it back on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 09:49:54 -07:00

1798 lines
79 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,
ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS,
validateTokenEndpointAuthMethod,
InvalidTokenEndpointAuthMethodError,
} from '../src/core/oauth-provider.ts';
import { hashToken, generateToken } from '../src/core/utils.ts';
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
import { InvalidTokenError, InvalidClientMetadataError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type { AuthInfo as CoreAuthInfo } from '../src/core/operations.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();
});
test('registerClientManual persists submit_agent bindings when supplied', async () => {
const { clientId } = await provider.registerClientManual(
'bound-agent', ['client_credentials'], 'read agent', [], 'default', undefined, undefined, {
boundTools: ['search', 'get_page'],
boundSourceId: 'dept-x',
boundBrainId: 'brain-a',
boundSlugPrefixes: ['wiki/agents/bound-agent/'],
boundMaxConcurrent: 2,
budgetUsdPerDay: '7.50',
},
);
const rows = await sql`
SELECT bound_tools, bound_source_id, bound_brain_id, bound_slug_prefixes,
bound_max_concurrent, budget_usd_per_day::text AS budget
FROM oauth_clients WHERE client_id = ${clientId}
`;
expect(rows[0].bound_tools).toEqual(['search', 'get_page']);
expect(rows[0].bound_source_id).toBe('dept-x');
expect(rows[0].bound_brain_id).toBe('brain-a');
expect(rows[0].bound_slug_prefixes).toEqual(['wiki/agents/bound-agent/']);
expect(Number(rows[0].bound_max_concurrent)).toBe(2);
expect(rows[0].budget).toBe('7.50');
});
});
// ---------------------------------------------------------------------------
// rescopeClient (#1914) — admin-gated rescope of a DCR-defaulted client
// ---------------------------------------------------------------------------
describe('rescopeClient', () => {
beforeAll(async () => {
// oauth_clients.source_id has FK → sources(id); create the targets.
for (const id of ['wiki', 'essays', 'alpha', 'gamma']) {
await sql`INSERT INTO sources (id, name) VALUES (${id}, ${id}) ON CONFLICT (id) DO NOTHING`;
}
});
test('DCR client stuck on default gets rescoped; existing tokens pick it up', async () => {
// Simulate the DCR path: self-registered client lands with
// source_id='default', federated_read=['default']. client_credentials
// over DCR needs the explicit --enable-dcr-insecure opt-in, so build a
// provider with that flag just for this registration.
const dcrProvider = new GBrainOAuthProvider({ sql, tokenTtl: 60, allowClientCredentialsDcr: true });
const dcr = await dcrProvider.clientsStore.registerClient!({
client_name: 'dcr-stuck-client',
redirect_uris: [],
grant_types: ['client_credentials'],
scope: 'read',
token_endpoint_auth_method: 'client_secret_post',
} as any);
const clientId = dcr.client_id;
const [before] = await sql`SELECT source_id, federated_read FROM oauth_clients WHERE client_id = ${clientId}`;
expect(before.source_id).toBe('default');
expect(before.federated_read).toEqual(['default']);
// Issue a token BEFORE the rescope — it must see the new scope after.
const tokens = await provider.exchangeClientCredentials(clientId, dcr.client_secret!, 'read');
const result = await provider.rescopeClient(clientId, {
sourceId: 'wiki',
federatedRead: ['wiki', 'essays'],
});
expect(result.sourceId).toBe('wiki');
expect(result.federatedRead).toEqual(['wiki', 'essays']);
const authInfo = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
expect(authInfo.sourceId).toBe('wiki');
expect(authInfo.allowedSources).toEqual(['wiki', 'essays']);
});
test('partial rescope leaves the other axis untouched', async () => {
const { clientId } = await provider.registerClientManual(
'partial-rescope', ['client_credentials'], 'read', [], 'alpha', ['alpha', 'beta'],
);
const result = await provider.rescopeClient(clientId, { federatedRead: ['beta'] });
expect(result.sourceId).toBe('alpha'); // untouched
expect(result.federatedRead).toEqual(['beta']);
const result2 = await provider.rescopeClient(clientId, { sourceId: 'gamma' });
expect(result2.sourceId).toBe('gamma');
expect(result2.federatedRead).toEqual(['beta']); // untouched
});
test('rejects invalid source ids, empty federated list, no-op calls, unknown client', async () => {
const { clientId } = await provider.registerClientManual(
'rescope-validation', ['client_credentials'], 'read',
);
await expect(provider.rescopeClient(clientId, { sourceId: '../etc' })).rejects.toThrow('Invalid source_id');
await expect(provider.rescopeClient(clientId, { federatedRead: ['ok', 'Not Valid!'] })).rejects.toThrow('Invalid source_id');
await expect(provider.rescopeClient(clientId, { federatedRead: [] })).rejects.toThrow('cannot be empty');
await expect(provider.rescopeClient(clientId, {})).rejects.toThrow('requires --source, --federated-read, and/or --bound-slug-prefixes');
// v0.42.70.0: an explicit empty prefix list is ambiguous (deny-all) — rejected.
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [] })).rejects.toThrow('cannot be an empty list');
// An empty/whitespace ENTRY matches every slug under startsWith — it would
// look like a binding while fencing nothing. Rejected at every write surface.
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [''] })).rejects.toThrow('non-empty');
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['ok/', ' '] })).rejects.toThrow('non-empty');
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: [' ok/'] })).rejects.toThrow('whitespace');
// A boundary-less entry reads as a character prefix, so it would silently
// cover sibling namespaces (emp-alice -> emp-alice-2/...).
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-alice'] })).rejects.toThrow('must end with');
await expect(provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-alice/', 'chan-eng'] })).rejects.toThrow('must end with');
await expect(provider.registerClientManual(
'empty-prefix-reject', ['client_credentials'], 'read write', [], 'default', undefined, undefined,
{ boundSlugPrefixes: [''] },
)).rejects.toThrow('non-empty');
await expect(provider.rescopeClient('gbrain_cl_nonexistent', { sourceId: 'wiki' })).rejects.toThrow('No OAuth client found');
// FK: write source must exist in sources(id).
await expect(provider.rescopeClient(clientId, { sourceId: 'no-such-source' })).rejects.toThrow('does not exist');
// Validation failures must not have mutated the row.
const [row] = await sql`SELECT source_id FROM oauth_clients WHERE client_id = ${clientId}`;
expect(row.source_id).toBe('default');
});
// v0.42.70.0: bound_slug_prefixes rescope — roster churn (channel
// joins/leaves) updates the write fence in place; 'none' (null) clears it.
test('bound_slug_prefixes: replace, leave-untouched, and clear; live tokens pick it up', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'rescope-fence', ['client_credentials'], 'read write', [], 'default', undefined, undefined, {
boundSlugPrefixes: ['emp-carol/'],
},
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret!, 'read write');
// Replace the binding (carol joins chan-eng).
const replaced = await provider.rescopeClient(clientId, { boundSlugPrefixes: ['emp-carol/', 'chan-eng/'] });
expect(replaced.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
expect(replaced.sourceId).toBe('default'); // untouched
// The already-issued token sees the new binding on next verification.
const live = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
expect(live.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
// Rescoping another axis leaves the binding untouched — and doesn't even
// name the column, so brains predating it can still rescope --source.
// `undefined` here means "not read this call", distinct from null = unset.
const other = await provider.rescopeClient(clientId, { federatedRead: ['alpha'] });
expect(other.boundSlugPrefixes).toBeUndefined();
const stillBound = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
expect(stillBound.boundSlugPrefixes).toEqual(['emp-carol/', 'chan-eng/']);
// null clears it — client returns to unbound full-source write authority.
const cleared = await provider.rescopeClient(clientId, { boundSlugPrefixes: null });
expect(cleared.boundSlugPrefixes).toBeNull();
const unfenced = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
expect(unfenced.boundSlugPrefixes).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 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;
if (!result.clientSecret) throw new Error('test bug: expected confidential client to have secret');
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);
});
// v0.42.70.0: bound_slug_prefixes threads through token verification on
// the same JOIN as source_id/federated_read, so enforceClientSlugFence
// can fence direct writes without a per-op DB lookup.
test('bound_slug_prefixes threads into AuthInfo; absent binding stays undefined', async () => {
const bound = await provider.registerClientManual(
'fence-thread-test', ['client_credentials'], 'read write', [], 'default', undefined, undefined, {
boundSlugPrefixes: ['chan-eng/', 'wiki/agents/fence-thread-test/'],
},
);
const boundTokens = await provider.exchangeClientCredentials(bound.clientId, bound.clientSecret!, 'read write');
const boundInfo = await provider.verifyAccessToken(boundTokens.access_token) as unknown as CoreAuthInfo;
expect(boundInfo.boundSlugPrefixes).toEqual(['chan-eng/', 'wiki/agents/fence-thread-test/']);
const unbound = await provider.registerClientManual(
'fence-unbound-test', ['client_credentials'], 'read write',
);
const unboundTokens = await provider.exchangeClientCredentials(unbound.clientId, unbound.clientSecret!, 'read write');
const unboundInfo = await provider.verifyAccessToken(unboundTokens.access_token) as unknown as CoreAuthInfo;
expect(unboundInfo.boundSlugPrefixes).toBeUndefined();
});
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');
});
// v0.36.1.x #935: the SDK's requireBearerAuth middleware only returns 401
// on InvalidTokenError; bare Error falls through to 500. Lock in the class.
test('verifyAccessToken throws InvalidTokenError (not bare Error) on expired token', async () => {
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})
`;
let caught: unknown;
try {
await provider.verifyAccessToken(expiredToken);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(InvalidTokenError);
});
test('verifyAccessToken throws InvalidTokenError (not bare Error) on unknown token', async () => {
let caught: unknown;
try {
await provider.verifyAccessToken('nonexistent-token');
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(InvalidTokenError);
});
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
});
test('legacy access_tokens fallback honors permissions.source_id array grants', async () => {
// oauth.test.ts initializes the static PGLite schema blob, not the full
// migration stack. Add the v38 permissions column here so the row matches
// a modern brain carrying a legacy-token source grant.
await sql`
ALTER TABLE access_tokens
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
`;
const legacyToken = generateToken('gbrain_');
const hash = hashToken(legacyToken);
await sql`
INSERT INTO access_tokens (id, name, token_hash, permissions)
VALUES (
${crypto.randomUUID()},
${'legacy-federated-agent'},
${hash},
${JSON.stringify({ source_id: ['default', 'src-a', 'src-b'] })}::jsonb
)
`;
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
expect(authInfo.clientId).toBe('legacy-federated-agent');
expect(authInfo.sourceId).toBe('default');
expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']);
});
// -------------------------------------------------------------------------
// #2529 — legacy access_tokens fallback threads permissions.takes_holders
// into AuthInfo.takesHoldersAllowList. Each test adds the v29 permissions
// column idempotently and inserts `permissions` EXPLICITLY: the column's
// NOT NULL DEFAULT is '{"takes_holders":["world"]}', so relying on the
// default would silently turn an "absent key" case into a ['world'] case.
// -------------------------------------------------------------------------
async function insertLegacyTokenWithPermissions(
name: string,
permissions: Record<string, unknown> | undefined,
): Promise<string> {
await sql`
ALTER TABLE access_tokens
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
`;
const token = generateToken('gbrain_');
const hash = hashToken(token);
if (permissions === undefined) {
await sql`
INSERT INTO access_tokens (id, name, token_hash)
VALUES (${crypto.randomUUID()}, ${name}, ${hash})
`;
} else {
await sql`
INSERT INTO access_tokens (id, name, token_hash, permissions)
VALUES (${crypto.randomUUID()}, ${name}, ${hash}, ${JSON.stringify(permissions)}::jsonb)
`;
}
return token;
}
test('legacy token with takes_holders grant → takesHoldersAllowList threaded (#2529)', async () => {
const token = await insertLegacyTokenWithPermissions('takes-grant-agent', { takes_holders: ['world', 'brain'] });
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toEqual(['world', 'brain']);
});
test('legacy token with no takes_holders key → undefined (consumer defaults to world)', async () => {
const token = await insertLegacyTokenWithPermissions('takes-absent-agent', {});
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toBeUndefined();
});
test('legacy token with non-array takes_holders → undefined (fail-closed at consumer)', async () => {
const token = await insertLegacyTokenWithPermissions('takes-garbage-agent', { takes_holders: 'world' });
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toBeUndefined();
});
test('legacy token with empty-array takes_holders → [] preserved as explicit deny-all', async () => {
const token = await insertLegacyTokenWithPermissions('takes-denyall-agent', { takes_holders: [] });
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toBeDefined();
expect(authInfo.takesHoldersAllowList).toEqual([]);
});
test('legacy token with mixed-type takes_holders → non-string entries filtered', async () => {
const token = await insertLegacyTokenWithPermissions('takes-mixed-agent', { takes_holders: ['world', 42, null] });
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toEqual(['world']);
});
test('OAuth-client token → takesHoldersAllowList undefined (no per-client storage; fail-closed)', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'takes-oauth-client', ['client_credentials'], 'read',
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret!, 'read');
const authInfo = await provider.verifyAccessToken(tokens.access_token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toBeUndefined();
});
test('legacy token relying on the v29 column default → ["world"] (fix invisible to unrestricted tokens)', async () => {
const token = await insertLegacyTokenWithPermissions('takes-default-agent', undefined);
const authInfo = await provider.verifyAccessToken(token) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toEqual(['world']);
});
});
// ---------------------------------------------------------------------------
// 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.
// v0.38 added 'agent' for submit_agent (D13).
expect([
'read', 'write', 'admin', 'sources_admin', 'users_admin', 'agent',
]).toContain(op.scope);
}
});
test('mutating operations are write/admin/sources_admin/users_admin/agent scoped unless remote-gated', () => {
const { operations } = require('../src/core/operations.ts');
// #2598, same allowlist as test/operations-trust-boundary.test.ts: think
// is read-scoped for OAuth/MCP because its handler forces save/take off
// for remote callers before persistence (pinned by
// test/takes-mcp-allowlist.serial.test.ts); local CLI can still persist.
const remoteReadOnlyMutatingOps = new Set(['think']);
for (const op of operations) {
if (op.mutating) {
if (remoteReadOnlyMutatingOps.has(op.name)) {
expect(op.scope, `${op.name} remote-gated mutating op should be read-scoped`).toBe('read');
continue;
}
// v0.28: sources_admin permits sources_add / sources_remove (mutating
// sources, not pages); read scope is the only thing too narrow for
// a mutating op unless its remote path forces persistence off before
// the handler writes. v0.38: 'agent' is a mutating-axis scope for
// submit_agent (creates jobs, spends money, but contained by bindings).
expect(
['write', 'admin', 'sources_admin', 'users_admin', 'agent'],
`${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');
});
});
// ---------------------------------------------------------------------------
// v0.41.3 — T1: ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS + validator
// ---------------------------------------------------------------------------
describe('v0.41.3 ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS', () => {
test('Set contains exactly the three SDK-advertised methods', () => {
expect(ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.size).toBe(3);
expect(ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has('client_secret_post')).toBe(true);
expect(ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has('client_secret_basic')).toBe(true);
expect(ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has('none')).toBe(true);
});
test('client_secret_basic is included — codex F3 regression', () => {
// The codex outside-voice review caught that omitting client_secret_basic
// would break operators using HTTP Basic for confidential client auth at
// the /token endpoint (server already supports it at serve-http.ts:468).
expect(ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has('client_secret_basic')).toBe(true);
});
});
describe('v0.41.3 validateTokenEndpointAuthMethod', () => {
test('undefined → "client_secret_post" (RFC 7591 default)', () => {
expect(validateTokenEndpointAuthMethod(undefined)).toBe('client_secret_post');
});
test('null → "client_secret_post"', () => {
expect(validateTokenEndpointAuthMethod(null)).toBe('client_secret_post');
});
test('empty string → "client_secret_post"', () => {
expect(validateTokenEndpointAuthMethod('')).toBe('client_secret_post');
});
test('"client_secret_post" → "client_secret_post"', () => {
expect(validateTokenEndpointAuthMethod('client_secret_post')).toBe('client_secret_post');
});
test('"client_secret_basic" → "client_secret_basic"', () => {
expect(validateTokenEndpointAuthMethod('client_secret_basic')).toBe('client_secret_basic');
});
test('"none" → "none" (public PKCE client)', () => {
expect(validateTokenEndpointAuthMethod('none')).toBe('none');
});
test('unknown method throws InvalidTokenEndpointAuthMethodError', () => {
expect(() => validateTokenEndpointAuthMethod('frobnicate')).toThrow(InvalidTokenEndpointAuthMethodError);
});
test('error message names the bad value + all allowed methods', () => {
try {
validateTokenEndpointAuthMethod('frobnicate');
throw new Error('should have thrown');
} catch (e: any) {
expect(e.message).toContain('frobnicate');
expect(e.message).toContain('client_secret_post');
expect(e.message).toContain('client_secret_basic');
expect(e.message).toContain('none');
}
});
test('non-string input throws', () => {
expect(() => validateTokenEndpointAuthMethod(123 as any)).toThrow(InvalidTokenEndpointAuthMethodError);
expect(() => validateTokenEndpointAuthMethod({} as any)).toThrow(InvalidTokenEndpointAuthMethodError);
expect(() => validateTokenEndpointAuthMethod([] as any)).toThrow(InvalidTokenEndpointAuthMethodError);
});
test('InvalidTokenEndpointAuthMethodError has stable error code', () => {
try {
validateTokenEndpointAuthMethod('xyz');
throw new Error('should have thrown');
} catch (e: any) {
expect(e.code).toBe('invalid_token_endpoint_auth_method');
expect(e.name).toBe('InvalidTokenEndpointAuthMethodError');
}
});
});
// ---------------------------------------------------------------------------
// v0.41.3 — T2: registerClientManual tokenEndpointAuthMethod parameter
// ---------------------------------------------------------------------------
describe('v0.41.3 registerClientManual tokenEndpointAuthMethod', () => {
test('omitted → confidential client with secret (back-compat)', async () => {
const result = await provider.registerClientManual(
'v413-default-test', ['client_credentials'], 'read',
);
expect(result.clientId).toStartWith('gbrain_cl_');
expect(result.clientSecret).toBeDefined();
expect(result.clientSecret!).toStartWith('gbrain_cs_');
});
test('explicit client_secret_post → confidential client with secret', async () => {
const result = await provider.registerClientManual(
'v413-csp-test', ['client_credentials'], 'read', [], 'default', undefined, 'client_secret_post',
);
expect(result.clientSecret).toBeDefined();
});
test('explicit client_secret_basic → confidential client with secret', async () => {
const result = await provider.registerClientManual(
'v413-csb-test', ['client_credentials'], 'read', [], 'default', undefined, 'client_secret_basic',
);
expect(result.clientSecret).toBeDefined();
});
test('"none" → public client with NO secret (T2 atomic INSERT)', async () => {
// The pre-v0.41.3 admin endpoint did INSERT (confidential) → UPDATE
// (NULL out secret_hash) for the 'none' case, leaving a confidential
// row stranded if the UPDATE failed (codex F4). T2 moves this into
// registerClientManual itself as a single atomic INSERT.
const result = await provider.registerClientManual(
'v413-public-test', ['authorization_code'], 'read',
['https://example.test/cb'], 'default', undefined, 'none',
);
expect(result.clientId).toStartWith('gbrain_cl_');
expect(result.clientSecret).toBeUndefined();
// Verify the stored row has client_secret_hash = NULL (public client shape)
const client = await provider.clientsStore.getClient(result.clientId);
expect(client).toBeDefined();
expect(client!.client_secret).toBeUndefined();
expect(client!.token_endpoint_auth_method).toBe('none');
});
test('unknown auth method throws InvalidTokenEndpointAuthMethodError at registration boundary', async () => {
await expect(
provider.registerClientManual(
'v413-bad-test', ['client_credentials'], 'read', [], 'default', undefined, 'frobnicate',
),
).rejects.toThrow(InvalidTokenEndpointAuthMethodError);
});
});
// ---------------------------------------------------------------------------
// v0.41.3 — T5: DCR /register handler applies the same validator
// ---------------------------------------------------------------------------
describe('v0.41.3 DCR validator (T5)', () => {
test('DCR rejects unknown token_endpoint_auth_method — closes --enable-dcr loose path', async () => {
// Pre-v0.41.3 the DCR registration handler defaulted to 'client_secret_post'
// for any unknown value, silently swallowing typos. T5 throws so the bad
// input fails loud — same gate as CLI + admin paths.
await expect(
provider.clientsStore.registerClient!({
client_name: 'dcr-bad-test',
grant_types: ['authorization_code'],
scope: 'read',
redirect_uris: ['https://example.test/cb'],
token_endpoint_auth_method: 'frobnicate',
} as any),
).rejects.toThrow(InvalidTokenEndpointAuthMethodError);
});
test('DCR accepts "none" → public PKCE client', async () => {
const reg = await provider.clientsStore.registerClient!({
client_name: 'dcr-public-test',
grant_types: ['authorization_code'],
scope: 'read',
redirect_uris: ['https://example.test/cb'],
token_endpoint_auth_method: 'none',
} as any);
expect(reg.client_id).toStartWith('gbrain_cl_');
// RFC 7591 §3.2.1: public clients MUST NOT receive a client_secret
expect(reg.client_secret).toBeUndefined();
});
test('DCR accepts "client_secret_basic" — codex F3 regression', async () => {
const reg = await provider.clientsStore.registerClient!({
client_name: 'dcr-basic-test',
grant_types: ['authorization_code'],
scope: 'read',
redirect_uris: ['https://example.test/cb'],
token_endpoint_auth_method: 'client_secret_basic',
} as any);
expect(reg.client_id).toStartWith('gbrain_cl_');
expect(reg.client_secret).toStartWith('gbrain_cs_');
});
});
describe('#1353 DCR default-grant hardening', () => {
test('DCR rejects explicit client_credentials by default', async () => {
await expect(
provider.clientsStore.registerClient!({
client_name: 'cc-default-test',
grant_types: ['client_credentials'],
scope: 'read',
redirect_uris: [],
token_endpoint_auth_method: 'client_secret_post',
} as any),
).rejects.toThrow(InvalidClientMetadataError);
});
test('DCR defaults to authorization_code when grant_types unspecified', async () => {
const reg = await provider.clientsStore.registerClient!({
client_name: 'no-grant-test',
scope: 'read',
redirect_uris: ['https://example.test/cb'],
token_endpoint_auth_method: 'none',
} as any);
const stored = await provider.clientsStore.getClient(reg.client_id);
expect(stored?.grant_types).toEqual(['authorization_code']);
});
test('--enable-dcr-insecure (allowClientCredentialsDcr) permits client_credentials', async () => {
const insecure = new GBrainOAuthProvider({ sql, allowClientCredentialsDcr: true });
const reg = await insecure.clientsStore.registerClient!({
client_name: 'cc-allowed-test',
grant_types: ['client_credentials'],
scope: 'read',
redirect_uris: [],
token_endpoint_auth_method: 'client_secret_post',
} as any);
const stored = await insecure.clientsStore.getClient(reg.client_id);
expect(stored?.grant_types).toEqual(['client_credentials']);
});
});