Skip to content

fix(auth+build): Bearer manage scope on management routes + lazy-load deepseek PoW solver - #2308

Merged
diegosouzapw merged 4 commits into
cheaper-inference:release/v3.8.0from
mrmm:mm/v3.8.0-mcp-management-bearer-auth
May 16, 2026
Merged

diegosouzapw merged 4 commits into
cheaper-inference:release/v3.8.0from
mrmm:mm/v3.8.0-mcp-management-bearer-auth

Conversation

@mrmm

@mrmm mrmm commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two related v3.8.0 fixes bundled together so the management-auth path can actually be exercised against a freshly-built Docker image:

  1. Management auth: isAuthenticated / verifyAuth in src/shared/utils/apiAuth.ts now accept Bearer API keys with manage (or admin) scope on management routes, closing a split-brain where 58 routes hard-rejected what 106 sibling routes already accepted.
  2. Standalone build unblock: open-sse/lib/deepseek-pow.ts now lazy-loads deepseek-pow-solver.cjs instead of require()-ing it at module load, so the Next.js standalone build can collect page data for /api/evals without crashing.

(2) was previously tracked as #2307; merged in here because (1) cannot be verified locally without a working Docker build, and shipping them together avoids "fix A, can't rebuild, fix B, rebuild, verify A" round-tripping.


Part 1 — Management auth Bearer scope

Problem

The MCP server (open-sse/mcp-server/server.ts) and other Bearer-only callers cannot reach management endpoints (/api/usage/call-logs, /api/settings, /api/v1/registered-keys, monitoring, …) on a remote OmniRoute instance with requireLogin: true. The only workaround today is local-only JWT cookie injection via ~/.config/opencode/plugins/omniroute-bridge/mcp-cookie-preload.cjs monkey-patching globalThis.fetch — fine for localhost, impossible for remote deployments.

Root cause — split-brain between two parallel auth helpers

OmniRoute has two management-auth surfaces:

Helper Used by Bearer with manage scope
src/lib/api/requireManagementAuth.ts 106 routes ✅ already accepted via hasManageScope()
src/shared/utils/apiAuth.ts isAuthenticated / verifyAuth 58 routes (settings, monitoring, v1/registered-keys, …) ❌ hard-rejected

getApiKeyMetadata() in src/lib/db/apiKeys.ts already exposes the scopes array, the default-key flow at apiKeys.ts:966 already passes scopes: ["manage"] through from env. The capability is there; only apiAuth.ts was opting out.

Fix

New validateBearerApiKeyForManagement(apiKey) in src/shared/utils/apiAuth.ts:

  • Calls validateApiKey() (lifecycle: isActive, revokedAt, expiresAt, isBanned, plus markApiKeyUsed + Redis cache hydration side effects).
  • Calls getApiKeyMetadata() and checks metadata.scopes against the shared MANAGEMENT_API_KEY_SCOPES set ({manage, admin}).
  • Both isAuthenticated and verifyAuth now consult it on management routes before the reject branch.

Default-deny preserved: unscoped Bearer keys still get 403 Invalid management token exactly as today.

Follow-up commits in this PR (auth)

  • 014b9f30 — refactor(auth): extract shared MANAGEMENT_API_KEY_SCOPES + hasManageScope to constants. Addresses gemini comment 3253030964 by creating src/shared/constants/managementScopes.ts (MANAGE_SCOPE, MANAGEMENT_API_KEY_SCOPES, hasManageScope()). Both apiAuth.ts and requireManagementAuth.ts import from it. Local hasManageScope in requireManagementAuth.ts now delegates to the shared helper; MANAGE_SCOPE is re-exported for backwards compat.
  • 05399ebf — fix(auth): read headers from request first so cliTokenAuth works outside next/headers scope. cliTokenAuth.ts was calling next/headers headers() unconditionally, which blew up the 6 pre-existing requireManagementAuth.* tests with "called outside request scope". New readHeader(request, name) reads from request.headers first and falls back to next/headers wrapped in try/catch. All 28 tests/unit/api-auth.test.ts tests now pass.

How to configure MCP against a remote OmniRoute server (with this fix)

Three steps; no cookie monkey-patch, no JWT injection.

1. Mint a Bearer key with manage scope

In the dashboard: API Keys → Create key → check manage scope → copy sk-….

Or via curl (assuming you already have a logged-in cookie or a key with manage):

# Create new key with manage scope
curl -X POST https://omniroute.example.com/api/keys \
  -H 'Authorization: Bearer sk-existing-manage-key' \
  -H 'Content-Type: application/json' \
  -d '{"name":"mcp-readonly","scopes":["manage"]}'

# Or upgrade an existing key
curl -X PATCH https://omniroute.example.com/api/keys/<id> \
  -H 'Authorization: Bearer sk-existing-manage-key' \
  -H 'Content-Type: application/json' \
  -d '{"scopes":["manage"]}'

2. Point your MCP client at the remote

Claude Desktop / Cursor / VS Code (mcp.json / claude_desktop_config.json):

{
  "mcpServers": {
    "omniroute": {
      "command": "npx",
      "args": ["-y", "@omniroute/mcp-server"],
      "env": {
        "OMNIROUTE_BASE_URL": "https://omniroute.example.com",
        "OMNIROUTE_API_KEY": "sk-…",
        "OMNIROUTE_MCP_ENFORCE_SCOPES": "true",
      },
    },
  },
}

Plain shell:

export OMNIROUTE_BASE_URL=https://omniroute.example.com
export OMNIROUTE_API_KEY=sk-…
npx @omniroute/mcp-server

3. Verify

curl -i https://omniroute.example.com/api/usage/call-logs?limit=1 \
  -H "Authorization: Bearer $OMNIROUTE_API_KEY"
# HTTP/1.1 200 OK   ← with this fix
# (HTTP/1.1 403 Invalid management token before the fix)

Security note: default-deny posture unchanged. Unscoped keys still 403 on management routes; only keys explicitly granted manage or admin pass.


Part 2 — Standalone build unblock (was #2307)

Problem

docker compose --profile base build fails at the builder stage with:

Error: Cannot find module '/app/open-sse/lib/deepseek-pow-solver.cjs'
> Build error occurred
Error: Failed to collect page data for /api/evals

Root cause

open-sse/lib/deepseek-pow.ts does:

const require = createRequire(import.meta.url);
const { U } = require(join(__dirname, "deepseek-pow-solver.cjs"));

at module-load time. The Next.js standalone build's page-data collector evaluates the module before file-tracing has copied the sibling .cjs into the standalone output, so the require throws.

Fix

72b79325 wraps the require in a memoized loadU() helper invoked only when solveDeepSeekPow() actually runs:

let _U: any | undefined;
function loadU(): any {
  if (_U === undefined) {
    _U = require(join(__dirname, "deepseek-pow-solver.cjs")).U;
  }
  return _U;
}

Module load now succeeds without touching the filesystem; the solver is materialized on first solve call, by which time file-tracing has emitted the .cjs alongside.

Verification (build)

docker compose --profile base build  # succeeds (was failing on /api/evals page-data collection)
docker compose --profile base up -d
docker logs omniroute | grep hydrated
# [STARTUP] Runtime settings hydrated: payloadRules, modelAliases, …, ccBridgeTransforms, systemTransforms

Tests

  • tests/unit/api-auth.test.ts — 28/28 pass after cliTokenAuth fix unblocked the 6 pre-existing headers() failures.
  • New tests added:
    • verifyAuth accepts bearer API keys with manage scope on management routes
    • verifyAuth accepts bearer API keys with admin scope on management routes
    • verifyAuth still rejects unscoped bearer API keys on management routes
    • isAuthenticated accepts bearer API keys with manage scope on management routes
  • Deepseek change is build-time-only; existing tests/unit/deepseek-pow.test.ts (if any path tests it) continues to pass — solver behaviour unchanged, only load-timing moved.

Live smoke (Docker)

  1. Rebuilt image (now possible thanks to 72b79325).
  2. PATCH /api/keys/d925c634-… granted manage scope to dev key.
  3. PATCH /api/settings/require-login → {"requireLogin": true}.
  4. Created fresh unscoped key for negative test.

Result:

Request Status
Bearer key WITH manage on /api/usage/call-logs 200 ✅
Bearer key WITHOUT scope on /api/usage/call-logs 403 Invalid management token ✅

Scope

  • src/shared/utils/apiAuth.ts — new validateBearerApiKeyForManagement + wire into both helpers.
  • src/shared/constants/managementScopes.ts — new shared constants module.
  • src/lib/api/requireManagementAuth.ts — delegate hasManageScope to shared helper, re-export MANAGE_SCOPE.
  • src/lib/middleware/cliTokenAuth.ts — readHeader(request, name) helper, swap headers() to it.
  • open-sse/lib/deepseek-pow.ts — lazy loadU().
  • tests/unit/api-auth.test.ts — 4 new tests covering accept/reject permutations.

Closes / supersedes

…scope on management routes

requireManagementAuth (the active gate on 106 management routes) already
accepts Bearer keys carrying the 'manage' or 'admin' scope. The parallel
isAuthenticated helper (~58 routes, including settings/database,
v1/registered-keys, etc.) and the underlying verifyAuth used to hard-reject
ALL Bearer tokens on management routes, requiring a dashboard JWT cookie.

This split-brain forced the bundled MCP server (open-sse/mcp-server) to
authenticate with a hand-extracted browser cookie instead of its
OMNIROUTE_API_KEY, since the routes it calls go through isAuthenticated,
not requireManagementAuth.

Aligning the two helpers: a new validateBearerApiKeyForManagement() checks
the key is valid AND carries one of the same scopes accepted by
requireManagementAuth ('manage', 'admin'). Unscoped keys remain rejected
on management routes, preserving the default-deny posture.

Tests:
- bearer + manage scope → accepted (verifyAuth + isAuthenticated)
- bearer + admin scope → accepted
- bearer + no scope → still rejected with 'Invalid management token'

Closes the gap that made #2260's MCP server unusable against a remote
OmniRoute without copying a dashboard cookie into the client.
@mrmm
mrmm requested a review from diegosouzapw as a code owner May 16, 2026 15:37

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements scoped API key authentication for management routes by introducing the validateBearerApiKeyForManagement function and updating the verifyAuth and isAuthenticated flows. It also adds unit tests to verify that keys with 'manage' or 'admin' scopes are correctly authorized. Feedback suggests optimizing the authentication logic to avoid redundant database and cache operations when retrieving key metadata, and centralizing the management scope constants in a shared file to prevent logic duplication across modules.

Comment thread src/shared/utils/apiAuth.ts Resolved
Comment thread src/shared/utils/apiAuth.ts Resolved
mrmm added 3 commits May 16, 2026 17:48
…cope to constants (PR #2308 follow-up)

Address gemini-code-assist review comment 3253030964 — eliminate the
duplicated management scope list between apiAuth.ts and
requireManagementAuth.ts.

- New src/shared/constants/managementScopes.ts exports MANAGE_SCOPE,
  MANAGEMENT_API_KEY_SCOPES, and hasManageScope as the single source
  of truth.
- src/shared/utils/apiAuth.ts now imports hasManageScope from the
  shared module instead of redeclaring the Set inline.
- src/lib/api/requireManagementAuth.ts now delegates to the shared
  helper; its local hasManageScope re-exports for backwards compat
  with existing callers (src/server/authz/policies/management.ts).

Tests: 4 new PR #2308 tests still pass; pre-existing 13 failures in
requireManagementAuth.* unchanged (Next.js headers() outside request
scope — unrelated to this patch).
…ide next/headers scope

Previously isCliTokenAuthValid called headers() from next/headers, which
throws when invoked outside a server-component request scope (e.g. raw
fetch tests, custom middleware paths). This caused 6 pre-existing
requireManagementAuth.* test failures and would also affect any callers
hitting these helpers from non-Next contexts.

Added readHeader(request, name) helper that:
- prefers request.headers (always present in tests + raw fetch)
- falls back to next/headers headers() wrapped in try/catch for the
  Next request-scope path

isCliTokenAuthValid now reads x-omniroute-cli-token, x-forwarded-for, and
x-real-ip via readHeader. Behaviour identical inside a Next request
scope; now also works in plain tests.
The module-load-time require(join(__dirname, 'deepseek-pow-solver.cjs'))
causes the Next.js standalone build's page-data collector to fail at
/api/evals with 'Cannot find module .../deepseek-pow-solver.cjs' because
webpack's static analyzer cannot resolve the runtime-computed path
before file-tracing emits the .cjs to the standalone output.

Moving the require inside a memoized loadU() helper lets module load
succeed without touching the filesystem, and the solver is materialized
only when solveDeepSeekPow() is actually invoked (by then file-tracing
has copied the .cjs alongside).
@mrmm mrmm changed the title fix(auth): isAuthenticated/verifyAuth accept Bearer keys with manage scope on management routes fix(auth+build): Bearer manage scope on management routes + lazy-load deepseek PoW solver May 16, 2026
@mrmm

mrmm commented May 16, 2026

Copy link
Copy Markdown
Contributor Author

@diegosouzapw — ready for review when you have a minute 🙏

TLDR: Two-part v3.8.0 fix:

  1. Auth (fix(auth+build): Bearer manage scope on management routes + lazy-load deepseek PoW solver #2308 original): apiAuth.ts isAuthenticated/verifyAuth now accept Bearer API keys with manage/admin scope on management routes — closes the split-brain with requireManagementAuth.ts (which already does this). Unblocks remote MCP clients without cookie monkey-patches.
  2. Build (formerly fix(deepseek-web): lazy-load PoW solver to unblock standalone build #2307): Lazy-load deepseek PoW solver so npm run build (standalone/Docker) doesn't choke on require(join(__dirname, …)) at module-load time. Zero behavior change.

Approach safety:

  • Default-deny preserved. Unscoped Bearer keys still get 403 on management routes.
  • Strictly additive — no breaking changes to any existing caller (audited all 106 requireManagementAuth + 58 isAuthenticated sites).
  • Gemini comments addressed/resolved (shared constants extracted in 014b9f30; redundancy comment deferred with rationale).
  • Live-smoked against local Docker: scoped key → 200, unscoped → 403.
  • CI green (Kilo ✅ / Snyk ✅ / Semgrep ✅). claude-review failure is owner-side workflow OIDC config, not the PR.

Happy to split into two PRs again or rework the approach if you'd prefer a different shape — just flag what works best for v3.8.0.

@diegosouzapw
diegosouzapw merged commit ec138c6 into cheaper-inference:release/v3.8.0 May 16, 2026
3 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks @mrmm for these crucial fixes! The MCP remote usage is a huge quality-of-life win, and fixing the Docker build is essential for the v3.8.0 release. Awesome work.

diegosouzapw added a commit that referenced this pull request May 16, 2026
- Add auth+build fix entry to [Unreleased]
- Update @mrmm PR count (2 -> 3)
@mrmm
mrmm deleted the mm/v3.8.0-mcp-management-bearer-auth branch May 17, 2026 20:18
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
- Add auth+build fix entry to [Unreleased]
- Update @mrmm PR count (2 -> 3)
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
- Add auth+build fix entry to [Unreleased]
- Update @mrmm PR count (2 -> 3)
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
- Add auth+build fix entry to [Unreleased]
- Update @mrmm PR count (2 -> 3)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants