Skip to content

Accept Kernel-issued OAuth tokens on the MCP endpoint - #207

Merged
rgarcia merged 13 commits into
mainfrom
hypeship/oauth-native-consumer
Nov 12, 2026
Merged

rgarcia merged 13 commits into
mainfrom
hypeship/oauth-native-consumer

Conversation

@rgarcia

@rgarcia rgarcia commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The MCP server accepts access tokens issued by Kernel's own authorization server (native OAuth) next to Clerk OAuth tokens and API keys. It verifies a native token locally, exchanges it (RFC 8693) for a short-lived API-audience token, and sends only that exchanged token to the Kernel API.

Companion PRs: kernel/kernel#5445 (auth server) and kernel/infra#2164 (API parameters).

Before / after

flowchart LR
  subgraph Before["Before: Clerk OAuth"]
    direction TB
    B1[MCP client] -->|protected-resource metadata| B2[MCP server]
    B1 -->|authorize + token<br/>Clerk sign-in, org picker| B3[Auth server<br/>Clerk-backed broker,<br/>or legacy MCP routes]
    B1 -->|Clerk JWT| B4[MCP server /mcp]
    B4 -->|verify with Clerk secret| B4
    B4 -->|same Clerk JWT| B5[Kernel API<br/>/auth/context, tools]
  end
  subgraph After["After: native OAuth"]
    direction TB
    A1[MCP client] -->|protected-resource metadata<br/>resource = mcp origin /mcp| A2[MCP server]
    A1 -->|authorization code + PKCE<br/>resource = mcp origin /mcp| A3[Kernel auth server]
    A1 -->|at+jwt, aud = mcp origin /mcp| A4[MCP server /mcp]
    A4 -->|verify locally with cached JWKS| A3
    A4 -->|encrypted exchanged token,<br/>keyed by HMAC of incoming token| A5[(Redis)]
    A4 -->|cache miss: RFC 8693 exchange<br/>resource = API origin| A3
    A4 -->|exchanged API-audience token| A6[Kernel API<br/>/auth/context, tools]
  end
Loading

What changed and why

  • Native token path. A JWT with typ: at+jwt is verified against the issuer's JWKS: issuer, exactly one audience equal to <mcp origin>/mcp, a lifetime of at most 10 minutes, and no prior exchange. It is then exchanged with resource=<API origin>, and the exchanged token's lineage is checked. It never falls back to Clerk.
  • Stable identity. The MCP Apps marker, the analytics session and the entitlements snapshot key on the verified sub and grant_id, not on the exchanged token, which changes on every exchange. Before this, app-only tools were denied after initialize, every request missed the entitlements cache, and analytics sessions rotated.
  • Shared exchanged-token cache.
    • Exchanged tokens are stored in Redis, encrypted with a key derived from the incoming token and keyed by an HMAC of it.
    • An entry expires a minute before the exchanged token does.
    • Every instance can reuse an entry, and a cold start costs only one exchange.
    • Revocation is unaffected: the incoming token is verified on every request, and the API checks the exchanged token on every call.
  • Configuration checked at startup.
    • The deployment is identified by its own public origin (MANAGED_AUTH_APP_ORIGIN), using the same table discovery uses.
    • Missing or inconsistent settings stop the instance before it serves requests, rather than failing each request.
    • On other origins, native OAuth is either fully configured or off.
  • Streams end with the credential. Every request is aborted when its exchanged token expires, so a stream never outlives its credential.
  • Metadata. Protected-resource metadata adds resource_name: "KERNEL" and resource_documentation: "https://www.kernel.sh/docs".

Configuration

MCP origin (MANAGED_AUTH_APP_ORIGIN, default production) Issuer API_BASE_URL must be
https://mcp.onkernel.com https://auth.onkernel.com https://api.onkernel.com
https://mcp.dev.onkernel.com https://auth.dev.onkernel.com https://api.dev.onkernel.com
any other (local) NATIVE_OAUTH_ISSUER the local API
Variable Production / staging Other origins
NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID required all three set, or none (off)
NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET required (sensitive) all three set, or none (off)
NATIVE_OAUTH_ISSUER must not be set all three set, or none (off)

Startup check: src/instrumentation.ts. next build does not run it. With next start, a failing check logs Failed to prepare server and every route returns 500. On Vercel, each function instance awaits the same registration before handling any request, so a misconfigured deployment serves nothing, including Clerk OAuth, API keys and discovery.

Deployment

This deploys fail-closed. Every Vercel environment that runs this code must have native settings that pass the startup check before it is deployed: production, the staging alias and previews. Otherwise that environment stops serving entirely.

This PR's own preview deployment shows the effect. It returns 500 on every route, including discovery, because the Preview scope has no native settings that pass the check. Before the startup check, the same preview served normally.

Provisioning (Vercel project mcp, per environment)

  1. Confirm MANAGED_AUTH_APP_ORIGIN is that environment's public origin: https://mcp.onkernel.com in Production and https://mcp.dev.onkernel.com for staging. Confirm API_BASE_URL is the matching API. If staging relies on the unset default, it would be treated as production and fail the check.
  2. Add NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID and NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET (marked sensitive) to each environment. Use the client id and raw secret whose SHA-256 digest that environment's API holds in NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET_HASH. Never share a secret across environments.
  3. Previews resolve the same way from the Preview scope's values. Either give the Preview scope staging's values, or point its MANAGED_AUTH_APP_ORIGIN at a non-Kernel origin without native settings (native off).
  4. Redeploy each environment after its variables are set.

Activation prerequisites

  1. kernel/infra#2164 is merged. kernel/kernel#5445 is deployed to the environment, with its native audience set to <mcp origin>/mcp and the exchange client id and secret digest configured.
  2. The MCP environment is provisioned as above, then this PR is deployed.
  3. In staging, set NATIVE_OAUTH_DEFAULT_NEW_FLOWS=true on the API and run the acceptance list below. Production follows only after staging passes and is approved.

Staging acceptance

  1. https://mcp.dev.onkernel.com/.well-known/oauth-protected-resource/mcp returns:
    • resource: https://mcp.dev.onkernel.com/mcp
    • authorization_servers: [https://auth.dev.onkernel.com]
    • resource_name: KERNEL
    • resource_documentation: https://www.kernel.sh/docs
  2. A fresh MCP client completes dynamic registration, PKCE login and consent. Its token has typ: at+jwt and aud: ["https://mcp.dev.onkernel.com/mcp"].
  3. initialize, tools/list and a project-scoped tool call succeed, and another project's resource is denied.
  4. Repeated requests with one access token make one exchange, even across instances. A refreshed token makes a new exchange.
  5. On an MCP Apps host, the managed-auth login app and its app-only tools work, including after a token refresh.
  6. A held stream closes when the exchanged token expires, and the client reconnects.
  7. Revoking the grant makes the next request return 401.
  8. Clerk OAuth and API-key connections behave as before.

Tests

  • Route regressions use rotating jti fixtures. They cover the App gate across rotated tokens, entitlements reuse, and analytics session stability; all three failed on the previous identity.
  • Exchange contract: resource, basic-auth encoding, OAuth error mapping, lineage failures, and capping at the signed expiry for issuer clocks ahead of or behind ours.
  • Cache: reuse across instances through a shared store, and expiry a minute before the exchanged token.
    • The stored value holds no token material.
    • A ciphertext moved to another token's key, or a corrupted entry, falls back to a fresh exchange.
    • A Redis error or stalled read also falls back to a fresh exchange.
    • Concurrent exchanges on one instance are coalesced.
  • Configuration: known origins require the exchange client and matching API, other origins accept all settings or none, and register() rejects invalid settings.
  • Streams: a legacy SSE response and a modern subscriptions/listen stream both close at the exchanged token's signed expiry.
  • Checks run: bunx tsc --noEmit, bun test, Prettier, check:managed-auth-app, and next build without native configuration.

@vercel

vercel Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
mcp Ready Ready Preview Nov 12, 2026 4:23pm UTC
mcp (staging) Ready Ready Preview Nov 12, 2026 4:23pm UTC

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread src/app/[transport]/route.ts Resolved
Comment thread src/lib/native-oauth.ts Resolved
…-consumer

# Conflicts:
#	src/app/[transport]/route.ts
Exchange MCP-audience tokens with the issuer's RFC 8693 profile, accept
its five-minute exchanged tokens while keeping a one-minute request lease,
check exchange lineage, require explicit configuration when enabled, and
leave legacy authorize/token/register and auth-context behavior unchanged.
@rgarcia rgarcia changed the title Add gated native OAuth verification and token exchange Add default-off native OAuth consumer for the Kernel issuer Nov 10, 2026
@rgarcia
rgarcia requested a review from masnwilliams November 10, 2026 16:34

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread src/app/[transport]/route.ts Resolved

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes for one reproduced bug: native callers get an identity the rest of the server doesn't recognize. The verification and exchange contract itself looks careful and well tested. Everything outside must fix is optional or can wait until before staging.

tsc is clean and the native, route and auth-login-app suites pass on 36f6300.

must fix

  1. Native callers get an inconsistent identity (inline on route.ts). The route records a native caller as native:<sub> with userId: null and forwards the exchanged token, which changes on every request. Code downstream rebuilds the caller's identity from authInfo.extra.userId and authInfo.token, so it reaches a different answer:

    • MCP Apps gate (reproduced): on a legacy-protocol client that declared MCP Apps, begin_auth_login / open_auth_login show up in tools/list, but every call returns the gate-denied message. The route saved the marker under native:…, and auth-login-app.ts looks it up under apikey:<hmac(exchanged token)>.
    • Entitlements cache (reproduced): the cache key includes token, so two native tools/list calls make two /org/entitlements calls. An API key makes one. Each miss also adds a fresh 30-minute entry to the shared 1,000-entry cache, which pushes out other callers' entries.
    • Analytics (same cause, not run): analyticsSessionKey falls back to token:<token> when userId is null, so every modern native request starts a new session.

    Fix: build authSubject with mcpAppsAuthSubject, set userId: native.subject (the only readers of extra.userId are the analytics session key and the auth-login gate), and keep the rotating token out of the entitlements cache key. That last part sits outside this diff, in handleMcpRequestWithIdentity:

@@ -214,6 +214,7 @@ async function handleMcpRequestWithIdentity({
   connectionContextCacheIdentity?: string;
+  entitlementsCacheToken?: string;
   observeConnection: boolean;
 }) {
@@ -248,7 +249,7 @@ async function handleMcpRequestWithIdentity({
           connection.context.authContext.organization.id,
           transportSessionId ?? "stateless",
-          token,
+          entitlementsCacheToken ?? token,
         ].join("\0"),

Then pass entitlementsCacheToken: "native" from the native branch. That's safe because the exchange has already authenticated this request. With these changes the gate passes (user:… on both sides), native makes one entitlements call, and the existing suites still pass.

  1. Make the fixture catch this (inline on the fixture). Every exchanged token gets jti: "jti_derived" with timestamps to the second, so two requests in the same second produce byte-identical tokens and the cache looks like it works. Give each one a unique jti, as the real issuer does, and add route tests for the MCP Apps gate and the entitlements call count. A condensed version of the repro I used is below.

suggestions (not blocking)

  1. leaseNativeResponse is probably redundant. The request already carries AbortSignal.timeout(deadline), and createMcpHandler tears down the transport when request.signal aborts (per-request transport, legacy SSE and subscriptions/listen). With the wrapper, the extra AbortController and its try/catch removed, a native subscriptions/listen stream with a 1s lease ended cleanly after about 1s through POST. That's −105/+39 lines including a route-level lease test. Keeping a second layer for a security cutoff is defensible, but then add a route-level test so the lease is tested end to end.
  2. Native error mapping duplicates connectionScopeFailureResponse and has already drifted (inline). A result type like McpConnectionContextFailure (rejected 401/403 or unavailable) would remove both exception classes, the instanceof chain and the throw that exists only to reach the route's own catch.
  3. Log which lineage check failed before it becomes a 503 (inline). As written, a staging failure only says "invalid native exchange".
  4. Parse config and keys once. Every native request runs JSON.parse + zod on the JWKS and calls createLocalJWKSet twice. Caching the config, or the error, keeps the "misconfigured means 503 for native only" behavior.

docs

  1. This repo is public, and docs/native-oauth.md plus two code comments reference internal API source paths, internal function names and a PR number in a private repo (inline). I'd keep the behavior and configuration tables here and track the activation prerequisites and staging checklist internally.
  2. Prerequisite 2 presents the scoped-operation coverage gap as fixed, but that coverage is being expanded on the API side. It'll go stale quickly, which is another reason to keep the checklist out of this doc.

nits

  1. jwtVerify accepts typ: "at+jwt" and normalizes the application/ prefix and case, so the manual typ check at native-oauth.ts:155 can go. Both test files also build the OAUTH_NATIVE_* env separately, with values that have already diverged. An issuer.env() on the fixture would cover both.
repro: MCP Apps gate + entitlements cache through POST
// Uses an in-memory stand-in for @/lib/redis that keys markers with the real mcpAppsMarkerKey,
// and gives each exchanged token a unique jti, as the deployed issuer does.
const fresh = async () => {
  issuer.deriveWith({ jti: crypto.randomUUID() });
  return issuer.parent();
};

// legacy flow: initialize (declares io.modelcontextprotocol/ui) -> tools/list -> tools/call
//   native:  listed=true, call -> "this tool is only available to the secure KERNEL login app…"
//            marker written under native:…, gate looked up apikey:…
//   api key: listed=true, call passes the gate
//   fixed:   native passes the gate (user:… on both sides)

// two stateless tools/list calls each
//   /org/entitlements calls: native 2, api key 1; fixed: native 1

Comment thread src/app/[transport]/route.ts Resolved
Comment thread src/lib/native-oauth.test-fixtures.ts Resolved
Comment thread src/app/[transport]/route.ts Resolved
Comment thread src/lib/native-oauth.ts Resolved
Comment thread src/lib/native-oauth.ts Resolved
Comment thread docs/native-oauth.md Resolved
Native callers keep a stable identity across rotating exchanged tokens:
MCP Apps markers, analytics sessions and the entitlements snapshot key on
the verified subject (and grant) instead of the exchanged token.

Trust follows the public MCP origin used for discovery, keys come from the
issuer's JWKS, the exchange sends resource=<API origin>, and exchanged
tokens are cached by incoming-token fingerprint with coalesced exchanges.
Requests and streams end with the exchanged token, so the response lease
wrapper is gone. Protected-resource metadata names the resource and its
documentation.
@rgarcia rgarcia changed the title Add default-off native OAuth consumer for the Kernel issuer Accept Kernel-issued OAuth tokens with cached JWKS verification and token exchange Nov 11, 2026
Native configuration is resolved once from this server's public origin and
checked in instrumentation, so missing or inconsistent settings stop the
instance instead of failing each request; other origins are fully
configured or off. Exchanged tokens are cached in Redis under an HMAC of
the incoming token, encrypted with a key derived from it, and verified again
before reuse. Protected-resource metadata uses the KERNEL name and docs URL,
and the docs keep only public behavior and configuration.
@rgarcia rgarcia changed the title Accept Kernel-issued OAuth tokens with cached JWKS verification and token exchange Accept Kernel-issued OAuth tokens on the MCP endpoint Nov 11, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1833abf. Configure here.

Comment thread src/lib/native-oauth.ts Resolved

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

lgtm. every finding from my earlier review is addressed on fd56366: identity now keys on the verified sub (and grant_id for entitlements), the fixture rotates exchanged tokens, the stream wrapper is gone in favor of the request signal, failures go through connectionScopeFailureResponse, lineage failures log which check failed, and config resolves once at startup. tsc is clean and the native, route, auth-login-app and discovery suites pass (110 tests).

the deploy concern from my last pass is also resolved: production, staging and preview now all pass the startup check, and the preview serves normally again with native off.

three optional suggestions, none blocking:

  1. scope the startup failure to native tokens. a missing or inconsistent native setting currently stops the whole instance, including Clerk OAuth, API keys and discovery. every environment passes today, but one bad env edit later would take down callers that never touch native OAuth. logging loudly at startup and answering native tokens with 503 would keep the signal without the blast radius.
  2. local dev heads-up. the old .env.example set MANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com, so a .env.local copied from it now resolves the production row and the dev server won't start without the exchange client. worth a line in the PR or README.
  3. nit: an invalid_scope answer from the exchange returns the shared 403 copy ("not scoped to the requested Kernel project"), which reads wrong for a scope rejection.

@rgarcia
rgarcia merged commit 4be0fdf into main Nov 12, 2026
9 checks passed
@rgarcia
rgarcia deleted the hypeship/oauth-native-consumer branch November 12, 2026 16:26

This branch was successfully deployed

2 active (1 outdated) deployments
Preview — ea2dee25 Deployed Nov 12, 2026 by vercel[bot]
staging — fd563668 Deployed Nov 12, 2026 by vercel[bot]
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