Files
gbrain/test/oauth.test.ts
T
01024567e3 v0.38.1.0 feat(agents): provider-agnostic subagent loop + remote MCP dispatch + budget meter (#1289)
* feat(agents): v0.38 Slice 1 foundation — migration v81 + capabilities module

Adds the storage substrate for the gateway-native subagent tool loop:

  - migration v81 adds subagent_tool_executions.ordinal + .gbrain_tool_use_id
    + UNIQUE(job_id, message_idx, ordinal). NULL-tolerant so legacy rows
    survive untouched; the v0.38 read-time D5 shim recomputes the stable
    key for pre-v81 rows from (job_id, message_idx, content_blocks index,
    tool_name) without a data migration. Engine-aware via sqlFor.pglite.
  - src/core/ai/capabilities.ts reads ChatTouchpoint fields from each
    recipe and exposes getProviderCapabilities() + classifyCapabilities()
    with a 5-state verdict (ok / degraded:no_caching / degraded:no_parallel
    / unusable:no_tools / unknown). This is what enforceSubagentCapable
    (D7, S1.8) will gate on once the queue.ts pin removal (S1.7) lands.
  - 12 unit cases in test/ai/capabilities.test.ts pin the verdict matrix
    across Anthropic, OpenAI, Google, voyage (no chat → unknown), unknown
    provider, missing-colon malformed input.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Wave: v0.38 (Agents+Minions cathedral; CEO + Eng + 2x Codex cleared).

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

* feat(agents): v0.38 Slice 1 — gateway.toolLoop() provider-agnostic loop control

Adds `gateway.toolLoop(opts)` as the provider-neutral loop wrapper over the
already-provider-neutral `gateway.chat()`. The Vercel AI SDK abstraction does
all the per-provider tool-def normalization, tool-call parsing, and tool-result
framing; this helper just sequences the assistant→tool-dispatch→tool-result
cycle with:

  - D11 stable-ID callbacks (onToolCallStart returns the gbrain-owned UUID v7
    that the caller persists at first observation; reread on replay)
  - Write-ordering invariant (persist assistant → persist pending tool row →
    execute side effect → settle complete/failed)
  - Crash-replay reconciliation via `replayState.priorTools` keyed by
    gbrainToolUseId (NOT provider IDs)
  - Capability-driven cache_control (Anthropic only, via cacheSystem flag)
  - Stop-reason mapping for refusal / content_filter / max_turns / aborted

The loop is stateless beyond the optional replay state — testable via the
existing `__setChatTransportForTests` seam without any DB.

This is the substrate Slice 1's `subagent.ts` rewire (S1.5) consumes.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 1 — kill the Anthropic pin, route through gateway.toolLoop

Closes the three-layer Anthropic-only enforcement (queue gate / model-config
runtime fallback / doctor check) with a capability-based gate driven by the
recipe registry. Any provider that supports native tool calling can now
run the subagent loop.

Three layers reworked:

  - queue.ts:87-106 (S1.7) — drop isAnthropicProvider hard-reject. Replace
    with classifyCapabilities() check: refuse only when verdict is
    'unusable:no_tools' or 'unknown'. Degraded providers (no caching, no
    parallel tools) pass through; the gateway prints once-per-(source, model)
    cost warnings at first dispatch.
  - model-config.ts:205 (S1.8) — rename enforceSubagentAnthropic →
    enforceSubagentCapable. Keeps the once-per-(source, model) warn seam
    from v0.31.12 and inherits the same suppression Set so doctor + first-
    call surfaces stay in sync. Legacy name kept as a thin wrapper for
    external callers.
  - doctor.ts:1189 (S1.9) — rename subagent_provider check →
    subagent_capability. The check now surfaces three states: 'unusable',
    'unknown', and 'degraded:no_caching' (the cost-regression warn). Paste-
    ready fix hints point at `gbrain config set models.tier.subagent`.

Subagent handler routing (S1.5 + S1.10):

  - New `agent.use_gateway_loop` config flag (default off). When enabled,
    the handler routes through gateway.toolLoop() — provider-agnostic via
    the Vercel AI SDK. When disabled, the legacy Anthropic-direct path
    stays unchanged.
  - Handler-entry capability check refuses tool-unsupported / unknown
    providers loudly. With flag OFF + non-Anthropic model, refuses with a
    paste-ready hint.
  - runSubagentViaGateway() (new helper) bridges the existing ToolDef
    registry to gateway's ChatToolDef + ToolHandler shapes. Persists to
    the v0.38 stable-ID columns (ordinal + gbrain_tool_use_id) at first
    observation; settles complete/failed on tool exit.
  - D5 read-time shim (S1.6) — loadPriorToolsV2 + adaptContentBlocksToChatBlocks
    handle v1 Anthropic-shaped legacy rows alongside v2 gateway-shaped writes
    so crash-replay reconciles across the upgrade boundary.

Tests:

  - test/agent-cli.test.ts Layer 1/2/3 cases flipped from "rejects non-
    Anthropic" to "any tool-supporting provider accepted; refuses unknown
    and embedding-only providers". 4 new cases covering openai, google,
    unknown provider, embedding-only.
  - All 27 cases pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 2 — budget meter (reserve-then-settle) + migrations v82/v83

Foundation for per-OAuth-client daily budget caps. The reserve-then-settle
pattern (D3) closes the race window where two concurrent agents from the
same client both pre-flight pass at the cap boundary and bust it. Mirrors
the rate-leases.ts shape (lock-bounded check-then-insert + TTL-based
crash reclamation).

Changes:

  - Migration v82 (`mcp_spend_reservations`) — UUID primary key per
    reservation, status enum {pending,settled,expired}, partial index on
    (status, expires_at) WHERE status='pending' for cheap sweeps.
  - Migration v83 (`oauth_clients.budget_usd_per_day`) — first-class
    daily cap column on registered clients. NULL = no cap (legacy
    behavior for pre-v83 clients).
  - `src/core/minions/budget-meter.ts` — new module:
      • `reserve()` atomic check-and-reserve: sweep expired → SUM
        committed + pending → refuse if over cap → INSERT pending row
      • `settle()` idempotent close-out: UPDATE reservation + mirror
        into mcp_spend_log so the next reserve sees the committed spend
      • `sweepExpiredReservations()` standalone sweeper for worker
        startup / test harness
      • `getClientDailyCapCents()` reads oauth_clients.budget_usd_per_day
      • `clientLockKey()` FNV-1a hash (deterministic, no deps) for
        pg_advisory_xact_lock keying
  - Reuses the existing `BudgetExceededError` class from `spend-log.ts`
    so callers (search_by_image + subagent dispatch + future surfaces)
    catch on the same tagged error.

All 130 migration tests green; budget-meter module typecheck clean.

The Slice 3 work (`submit_agent` MCP op) wires this meter into the
remote-dispatch path: serve-http.ts threads `client_id` through the
operation context, the subagent handler's gateway path calls
`reserve()` before the loop and `settle()` after.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 3 — submit_agent MCP op + agent scope + bound_* migration

The remote-dispatch unlock. Cursor / Claude Code / ChatGPT can now launch
gbrain agent jobs over MCP with explicit per-OAuth-client capability
binding (D13). The trust boundary lives in oauth_clients.bound_* fields,
not in ad-hoc protected-name checks.

Schema:

  - Migration v84 (`oauth_clients_agent_binding`) — adds bound_tools,
    bound_source_id (FK sources.id ON DELETE SET NULL), bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent columns. NULL on pre-v84
    clients (which therefore can't be granted the `agent` scope without
    re-registration — opt-in only).
  - `agent` scope added to `src/core/scope.ts`. NOT implied by admin
    (D13 sibling) — existing admin clients must explicitly re-register
    with --scopes agent to gain dispatch capability.

New MCP op `submit_agent`:

  - scope: `agent`, mutating, remote-callable
  - Required params: prompt. Optional: model, allowed_tools,
    allowed_slug_prefixes, max_turns (capped at 100), queue.
  - Per-dispatch binding enforcement:
      * client must have a binding row (refuse with paste-ready
        re-registration hint when bound_tools is NULL)
      * requested allowed_tools must be ⊆ bound_tools
      * requested slug_prefixes must each match a bound prefix
      * source_id auto-set from bound_source_id (client can't escape)
      * in-flight job count vs bound_max_concurrent
  - Internally enqueues a `subagent` job with allowProtectedSubmit;
    the gateway path (S1.5) is auto-on for remote-dispatched agents.
  - Writes a JSONL audit row via the new `agent-audit.ts` module:
    client_id + tools + source + slug_prefixes + max_concurrent +
    budget_remaining_cents + prompt byte count (NOT prompt text).

New `src/core/minions/agent-audit.ts`:

  - Mirrors shell-audit.ts (weekly ISO-week JSONL rotation, GBRAIN_AUDIT_DIR
    override, best-effort writes).
  - File: ~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl
  - `logAgentSubmission` + `readRecentAgentEvents` exported for the
    doctor follow-up.

Tests: typecheck clean; capabilities + agent-cli suites green (39/39).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* feat(agents): v0.38 Slice 4 — admin per-client agent spend endpoint

Read-side `/admin/api/agents/spend` endpoint returning per-OAuth-client
today's spend (committed + pending reservations), cap, and inflight job
count. The Agents.tsx page in admin/src/pages/ consumes this to render a
"$X / $Y today" cell next to each client.

Stub-style server endpoint lands now; the full Agents.tsx UI extension
can ship in a follow-up patch without blocking the Slices 1-3 functionality.
Pre-v0.38 brains where mcp_spend_log / mcp_spend_reservations may not
yet exist fall back to an empty array (graceful UI degrade).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* test(agents): v0.38 — gateway.toolLoop + budget-meter + agent-audit + scope flips

Test gap fills surfacing the load-bearing invariants of Slices 1-3:

Gateway tool loop (test/ai/gateway-tool-loop.test.ts, 7 cases):
  - end stop_reason exits cleanly with no tools
  - single tool call dispatches + result feeds next turn
  - persistence callbacks fire in order: onAssistantTurn → onToolCallStart
    → execute → onToolCallComplete (write-ordering invariant pinned)
  - replay short-circuit when prior tool execution is complete
  - non-idempotent pending replay throws unrecoverable
  - max_turns budget capped
  - refusal short-circuits without tool dispatch

Budget meter (test/minions/budget-meter.test.ts, 15 cases):
  - clientLockKey FNV-1a determinism + collision-rarity + INT32 fit
  - reserve under cap / over cap / two-sequential / pending-pushes-over
  - settle marks settled + mirrors to mcp_spend_log
  - settle idempotency (second call no-op)
  - sweep expired pending rows; leaves fresh ones
  - getClientDailyCapCents with set/unset/unknown clients
  - integration: settled spend feeds next reserve

Agent audit (test/minions/agent-audit.test.ts, 7 cases):
  - ISO-week filename rotation (incl. year-boundary edge)
  - JSONL line shape + multi-event appending
  - regression guard: NEVER logs prompt content (only byte count)
  - readRecentAgentEvents newest-first + empty-dir graceful fallback

Pre-existing test fixes for v0.38 semantics:
  - test/scope.test.ts: `agent` scope added (size 5 → 6)
  - test/oauth.test.ts: operations registry allows scope='agent' for
    submit_agent (mutating, contained by client bindings)
  - test/model-config.serial.test.ts: enforceSubagentCapable returns
    non-Anthropic tool-supporting models unchanged (with cost warn) and
    falls back to TIER_DEFAULTS.subagent only on unknown providers

Schema parity:
  - pglite-schema.ts + schema.sql get the v83 (budget_usd_per_day) +
    v84 (bound_tools, bound_source_id, bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent) columns in CREATE TABLE
    so fresh installs land in post-migration shape AND the
    schema-bootstrap-coverage CI guard sees full coverage.

Pre-existing hybrid-reranker / cross-modal-hybrid integration test
failures are on master before any of this wave — out of scope.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

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

* test: quarantine 4 cross-file-contended hybrid tests + withEnv-ize agent-audit

12 pre-existing flakes (hybrid-reranker / cross-modal-hybrid / unified-multimodal
/ llm-intent-hybrid-integration / doctor-report-remote) all collapsed to
zero after this wave. Root cause: shared module-level state in
src/core/ai/gateway.ts (configureGateway / __setEmbedTransportForTests /
_chatTransport) leaks across files in the same bun test process. Files
that touch the gateway state must run under --max-concurrency=1 (the
serial pass).

Renamed (R2 quarantine — gateway-state contention):
  - test/search/hybrid-reranker-integration.test.ts → .serial.test.ts
  - test/cross-modal-hybrid-integration.test.ts → .serial.test.ts
  - test/unified-multimodal.test.ts → .serial.test.ts
  - test/llm-intent-hybrid-integration.test.ts → .serial.test.ts

doctor-report-remote.serial.test.ts was already serial in v0.37.10.0; its
single failure in the v0.38 PR test log was downstream pollution from the
above four files leaking gateway transports across shard 3.

Also fixed test/minions/agent-audit.test.ts (R1 violation: raw
process.env.GBRAIN_AUDIT_DIR mutation) by wrapping each test body through
withEnv() via a withAuditDir() helper. check-test-isolation now passes
clean (526 non-serial unit files scanned, 0 violations).

Post-fix unit suite: 7/8 shards pass with zero failures; serial pass
29/29 clean; full run exit 0. Background task reported exit code 0.
The wedge on shard 4 (migrate.test.ts) is a separate slow-test scoping
concern, not a v0.38 regression.

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

* fix(admin): mirror v0.38 agent scope into admin SPA + rebuild dist

CI failure on PR #1289: scripts/check-admin-scope-drift.sh caught the
hand-maintained mirror at admin/src/lib/scope-constants.ts had not been
updated when I added the new `agent` scope to src/core/scope.ts in Slice 3.
CLAUDE.md flagged this exact CI guard for the file.

Mirrored: added `agent` to both the Scope union type and the alphabetically-
sorted ALLOWED_SCOPES_LIST. Rebuilt the admin SPA dist (vite build, 36
modules, 228KB) so the bundled scope-aware UI matches the new server-side
list. check-admin-scope-drift passes (6 scopes match); full `bun run verify`
chain passes end-to-end including typecheck (0 errors).

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

* fix(admin): regenerate src/admin-embedded.ts after dist rebuild

CI failure on PR #1289 serial pass: test/admin-embed-spawn.serial.test.ts
4/4 fail with "Cannot find module '../admin/dist/assets/index-CWq369vO.js'"
when spawning gbrain serve --http.

Root cause: the prior commit (f270e6c7) rebuilt the admin SPA dist after
adding the v0.38 `agent` scope to admin/src/lib/scope-constants.ts, which
produced a new content-hashed bundle filename (index-CWq369vO.js →
index-DFgMZhBE.js). The auto-generated `src/admin-embedded.ts` manifest
still hardcoded the OLD filename, so `import ... with { type: 'file' }`
threw at module-load time inside the spawned server, the server never
became ready, and the e2e harness timed out at 30s × 4 tests = ~2min.

Fix: re-ran `bun run build:admin-embedded` (scripts/build-admin-embedded.ts)
which regenerates src/admin-embedded.ts from the current dist/ contents.
Manifest now references index-DFgMZhBE.js. All 4 admin-embed-spawn.serial
tests pass locally.

Forward-looking note: the build:admin npm script chains
`cd admin && bun run build && cd .. && bun run scripts/build-admin-embedded.ts`
so regenerating both together is the standard path — the prior commit
manually invoked `vite build` inside admin/ and skipped the second step.

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

* chore: re-bump v0.38.2.0 → v0.38.1.0 (correct patch sequence)

User direction: this is v0.38.1.0 — the first patch on v0.38.0.0, not 0.38.2.0.
v0.38.2.0 was mid-wave when master merged in d0d0e2a6 and I chose the next
slot up; in retrospect 0.38.1.0 is the correct next-patch number since
nothing actually shipped at 0.38.0.0 yet (the PR's been re-iterating through
CI green and the wave is one continuous ship).

Updated:
  - VERSION: 0.38.2.0 → 0.38.1.0
  - package.json: 0.38.2.0 → 0.38.1.0
  - CHANGELOG.md: header rewritten

Trio audit: all three say 0.38.1.0.

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

* test: fill v0.38 gap inventory — submit_agent + scope isolation + D5 shim + admin spend

61 new test cases across 4 files closing the load-bearing gaps from the
v0.38 Agents+Minions wave. Also extracts /admin/api/agents/spend SQL into
a named helper so the endpoint and its test share a single source of truth.

Gap inventory + coverage delta:

  | Surface                                    | Before | After  |
  |--------------------------------------------|--------|--------|
  | submit_agent op (binding enforcement)      | 0      | 17     |
  | agent scope NOT implied by admin           | 0      | 9      |
  | D5 v1→v2 read-time shim                    | 0      | 16     |
  | /admin/api/agents/spend endpoint SQL       | 0      | 19     |

test/submit-agent.test.ts (17 cases):
  - Op surface (scope=agent, mutating, required prompt param)
  - Local CLI bypass (ctx.remote=false → invalid_request)
  - OAuth client requirement (missing clientId, unknown client_id)
  - Binding requirement: refuse when agent scope but bound_tools NULL
  - allowed_tools subset enforcement (passes ⊆, refuses outside)
  - allowed_slug_prefixes prefix-match against bound_slug_prefixes
  - bound_max_concurrent cap (refuse at cap, allow below, exclude
    terminal-state jobs, isolate inflight count by client_id)
  - Happy-path: job inserted + audit row written + prompt NEVER logged
  - max_turns capped at 100

test/scope-agent-isolation.test.ts (9 cases) — D13 regression guard:
  - admin does NOT imply agent (the load-bearing security check)
  - admin still implies sources_admin/users_admin/write/read
  - agent does NOT imply anything else (no reverse inheritance)
  - read+write does NOT imply agent (the common legacy shape)
  - explicit admin+agent compound grant satisfies both
  - ALLOWED_SCOPES_LIST sort order pinned (agent between admin and read)

test/subagent-v1-v2-shim.test.ts (16 cases) — D5 crash-replay correctness:
  - adaptContentBlocksToChatBlocks: string passthrough, defensive nulls,
    v1 Anthropic {type:tool_use,id,name,input} → v2 {type:tool-call,...},
    v2 passthrough, v1 tool_result → v2 tool-result with __legacy__
    toolName sentinel, is_error mapping, mixed v1+v2 in same message
    array (mid-upgrade scenario), malformed-block skip
  - loadPriorToolsV2: empty, gbrain_tool_use_id as stable key for v2,
    legacy-prefixed key for v1 rows, status+error preservation, mixed
    v1+v2 side-by-side with both shapes resolving, ORDER BY stability
  - Exposed both helpers on the existing __testing export from subagent.ts

test/admin-agents-spend.test.ts (19 cases) — Slice 4 SQL pinning:
  - Empty results: no clients / clients without agent scope or bindings
  - Include: scope=agent (with or without bindings), bound_tools set
    (with or without scope=agent — covers partial-migration state)
  - Exclude: soft-deleted (deleted_at IS NOT NULL) clients
  - cap_usd_per_day: null when unset, numeric when set
  - spent_cents_today: zero baseline, sum of today, exclude yesterday
    (UTC-day-aligned), client-id isolation
  - pending_cents: sum of pending+non-expired, exclude expired, exclude
    settled
  - inflight_count: only active/waiting/waiting-children subagent jobs;
    exclude shell jobs; client-id isolated
  - ORDER BY client_name ASC pinned for deterministic UI rendering
  - Multi-word scope strings ('read write agent') handled correctly via
    string_to_array
  - End-to-end happy path: all fields populated together

Refactor: extracted the spend SQL from src/commands/serve-http.ts into a
new exported `queryAgentClientSpend(engine)` helper + `AgentClientSpend`
type. The Express handler now delegates (5 lines). Same query, same
result shape, but a single source of truth that both the endpoint and
the test exercise.

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

* test(agents): v0.38 e2e — gateway path + crash-replay across 5 providers

Two new e2e suites driving the v0.38 runSubagentViaGateway path end-to-end
against PGLite. Both filed in TODOS as v0.38.x follow-ups during the cathedral
ship; building them out caught two real load-bearing bugs in subagent.ts that
would have silently broken crash-replay in production.

Bug 1 — messageIdx collision on fresh runs.
runSubagentViaGateway only passed replayState when priorChatMessages.length > 0,
so on a fresh run the gateway loop's messageIdx counter defaulted to 0. The
seed user message already occupies (job_id, message_idx=0), so the first
onAssistantTurn write at idx 0 hit the unique-constraint and the whole job
failed before any tool call. Fix: always pass replayState with nextMessageIdx
set to 1 on fresh runs (after the seed write). Pinned by
test/e2e/subagent-gateway-path.test.ts ("happy path 1-turn" + "write-ordering
invariant").

Bug 2 — onToolCallStart returned the wrong UUID on crash-replay.
The callback generated a fresh candidateId, INSERTed with ON CONFLICT DO
UPDATE, and returned the local candidateId. On replay, the pre-crash row
survives intact with its ORIGINAL gbrain_tool_use_id, so the local candidateId
was wrong. The gateway loop's replayState.priorTools is keyed by the original
UUID; returning the new one made the short-circuit miss and re-execute every
tool call. Fix: RETURNING gbrain_tool_use_id::text AS gbrain_tool_use_id and
read it back; fall through to candidateId only if RETURNING is empty. Pinned
by test/e2e/subagent-crash-replay-multi-provider.test.ts.

Coverage:
- test/e2e/subagent-gateway-path.test.ts: 7 cases. Happy path 1-turn,
  multi-turn with parallel tool calls, write-ordering invariant
  (persist-before-side-effect), gateway returns malformed tool_call shape,
  cancel mid-loop, capability refusal at submit.
- test/e2e/subagent-crash-replay-multi-provider.test.ts: 13 cases. Five
  provider rows (anthropic / openai / google / openrouter / deepseek) ×
  pre-crash run + replay assertion, plus ordinal-collision PK guard,
  pending-tool short-circuit, v1→v2 shim round-trip.

Both files run hermetically against PGLite (no DATABASE_URL needed) and
use the __setChatTransportForTests gateway seam for stubbed provider
responses. Reset path goes through resetPgliteState + setConfig version=84
so MinionQueue.ensureSchema() sees the migration ledger correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

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

1286 lines
55 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
import { GBrainOAuthProvider, coerceTimestamp } from '../src/core/oauth-provider.ts';
import { hashToken, generateToken } from '../src/core/utils.ts';
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
// ---------------------------------------------------------------------------
// Test setup: in-memory PGLite with OAuth tables
// ---------------------------------------------------------------------------
let db: PGlite;
let sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<any>;
let provider: GBrainOAuthProvider;
beforeAll(async () => {
db = new PGlite({ extensions: { vector, pg_trgm } });
await db.exec(PGLITE_SCHEMA_SQL);
// Create a tagged template wrapper for PGLite
sql = async (strings: TemplateStringsArray, ...values: unknown[]) => {
const query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? `$${i + 1}` : ''), '');
const result = await db.query(query, values as any[]);
return result.rows;
};
provider = new GBrainOAuthProvider({ sql, tokenTtl: 60, refreshTtl: 300 });
}, 30_000); // PGLITE_SCHEMA_SQL execution under full-suite load can exceed default 5s
afterAll(async () => {
if (db) await db.close();
}, 15_000);
// ---------------------------------------------------------------------------
// hashToken + generateToken utilities
// ---------------------------------------------------------------------------
describe('hashToken', () => {
test('produces consistent SHA-256 hex', () => {
const hash = hashToken('test-token');
expect(hash).toHaveLength(64);
expect(hashToken('test-token')).toBe(hash); // deterministic
});
test('different inputs produce different hashes', () => {
expect(hashToken('a')).not.toBe(hashToken('b'));
});
});
describe('generateToken', () => {
test('produces prefixed random hex', () => {
const token = generateToken('gbrain_cl_');
expect(token).toStartWith('gbrain_cl_');
expect(token).toHaveLength('gbrain_cl_'.length + 64); // 32 bytes = 64 hex chars
});
test('tokens are unique', () => {
const a = generateToken('test_');
const b = generateToken('test_');
expect(a).not.toBe(b);
});
});
// ---------------------------------------------------------------------------
// coerceTimestamp — postgres BIGINT-as-string boundary helper
// ---------------------------------------------------------------------------
describe('coerceTimestamp', () => {
test('null returns undefined', () => {
expect(coerceTimestamp(null)).toBeUndefined();
});
test('undefined returns undefined', () => {
expect(coerceTimestamp(undefined)).toBeUndefined();
});
test('numeric string coerces to number', () => {
// The actual production path: postgres-js with prepare:false returns
// BIGINT columns as strings.
expect(coerceTimestamp('12345')).toBe(12345);
expect(coerceTimestamp('1735689600')).toBe(1735689600);
});
test('native number passes through', () => {
// Direct-PG users on prepare:true get native numbers.
expect(coerceTimestamp(12345)).toBe(12345);
expect(coerceTimestamp(0)).toBe(0);
});
test('non-finite input throws (fail-closed contract)', () => {
// The load-bearing change vs Number(): corrupt rows fail loud at the
// boundary instead of letting NaN flow through to the SDK as a
// fake-valid `expiresAt`.
expect(() => coerceTimestamp('not-a-number')).toThrow(/non-finite/);
expect(() => coerceTimestamp(NaN)).toThrow(/non-finite/);
expect(() => coerceTimestamp(Infinity)).toThrow(/non-finite/);
expect(() => coerceTimestamp(-Infinity)).toThrow(/non-finite/);
});
});
// ---------------------------------------------------------------------------
// Client Registration
// ---------------------------------------------------------------------------
describe('client registration', () => {
test('registerClientManual creates a client', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'test-agent', ['client_credentials'], 'read write',
);
expect(clientId).toStartWith('gbrain_cl_');
expect(clientSecret).toStartWith('gbrain_cs_');
// Verify client exists in DB
const client = await provider.clientsStore.getClient(clientId);
expect(client).toBeDefined();
expect(client!.client_name).toBe('test-agent');
});
test('getClient returns undefined for unknown client', async () => {
const client = await provider.clientsStore.getClient('nonexistent');
expect(client).toBeUndefined();
});
test('duplicate client_id is rejected', async () => {
const { clientId } = await provider.registerClientManual(
'dup-test', ['client_credentials'], 'read',
);
// Try to insert same client_id directly
await expect(
sql`INSERT INTO oauth_clients (client_id, client_name, scope) VALUES (${clientId}, ${'dup'}, ${'read'})`,
).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// Client Credentials Exchange
// ---------------------------------------------------------------------------
describe('client credentials', () => {
let clientId: string;
let clientSecret: string;
beforeAll(async () => {
const result = await provider.registerClientManual(
'cc-test-agent', ['client_credentials'], 'read write',
);
clientId = result.clientId;
clientSecret = result.clientSecret;
});
test('valid exchange returns access token', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
expect(tokens.access_token).toStartWith('gbrain_at_');
expect(tokens.token_type).toBe('bearer');
expect(tokens.expires_in).toBe(60);
expect(tokens.scope).toBe('read');
});
test('no refresh token issued for CC grant', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
expect(tokens.refresh_token).toBeUndefined();
});
test('wrong secret is rejected', async () => {
await expect(
provider.exchangeClientCredentials(clientId, 'wrong-secret', 'read'),
).rejects.toThrow('Invalid client secret');
});
test('client without CC grant is rejected', async () => {
const { clientId: noCC } = await provider.registerClientManual(
'no-cc-agent', ['authorization_code'], 'read',
);
await expect(
provider.exchangeClientCredentials(noCC, 'any-secret', 'read'),
).rejects.toThrow('not authorized');
});
test('scope is filtered to allowed scopes', async () => {
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read write admin');
// Client only has 'read write', admin should be filtered out
expect(tokens.scope).not.toContain('admin');
});
});
// ---------------------------------------------------------------------------
// Token Verification
// ---------------------------------------------------------------------------
describe('verifyAccessToken', () => {
test('valid token returns auth info', async () => {
const { clientId, clientSecret } = await provider.registerClientManual(
'verify-test', ['client_credentials'], 'read write',
);
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
const authInfo = await provider.verifyAccessToken(tokens.access_token);
expect(authInfo.clientId).toBe(clientId);
expect(authInfo.scopes).toContain('read');
expect(authInfo.token).toBe(tokens.access_token);
});
test('expired token is rejected', async () => {
// Insert a token that's already expired
const expiredToken = generateToken('gbrain_at_');
const hash = hashToken(expiredToken);
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
await sql`
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${Math.floor(Date.now() / 1000) - 100})
`;
await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow('expired');
});
test('unknown token is rejected', async () => {
await expect(provider.verifyAccessToken('nonexistent-token')).rejects.toThrow('Invalid token');
});
// 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
});
});
// ---------------------------------------------------------------------------
// 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', () => {
const { operations } = require('../src/core/operations.ts');
for (const op of operations) {
if (op.mutating) {
// v0.28: sources_admin permits sources_add / sources_remove (mutating
// sources, not pages); read scope is the only thing too narrow for
// any mutating op. 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');
});
});