Repository navigation
Accept Kernel-issued OAuth tokens on the MCP endpoint - #207
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…-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.
masnwilliams
left a comment
There was a problem hiding this comment.
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
-
Native callers get an inconsistent identity (inline on
route.ts). The route records a native caller asnative:<sub>withuserId: nulland forwards the exchanged token, which changes on every request. Code downstream rebuilds the caller's identity fromauthInfo.extra.userIdandauthInfo.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_loginshow up intools/list, but every call returns the gate-denied message. The route saved the marker undernative:…, andauth-login-app.tslooks it up underapikey:<hmac(exchanged token)>. - Entitlements cache (reproduced): the cache key includes
token, so two nativetools/listcalls make two/org/entitlementscalls. 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):
analyticsSessionKeyfalls back totoken:<token>whenuserIdis null, so every modern native request starts a new session.
Fix: build
authSubjectwithmcpAppsAuthSubject, setuserId: native.subject(the only readers ofextra.userIdare 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, inhandleMcpRequestWithIdentity: - MCP Apps gate (reproduced): on a legacy-protocol client that declared MCP Apps,
@@ -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.
- 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 uniquejti, 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)
leaseNativeResponseis probably redundant. The request already carriesAbortSignal.timeout(deadline), andcreateMcpHandlertears down the transport whenrequest.signalaborts (per-request transport, legacy SSE andsubscriptions/listen). With the wrapper, the extraAbortControllerand its try/catch removed, a nativesubscriptions/listenstream with a 1s lease ended cleanly after about 1s throughPOST. 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.- Native error mapping duplicates
connectionScopeFailureResponseand has already drifted (inline). A result type likeMcpConnectionContextFailure(rejected401/403 orunavailable) would remove both exception classes, theinstanceofchain and the throw that exists only to reach the route's owncatch. - Log which lineage check failed before it becomes a 503 (inline). As written, a staging failure only says "invalid native exchange".
- Parse config and keys once. Every native request runs
JSON.parse+ zod on the JWKS and callscreateLocalJWKSettwice. Caching the config, or the error, keeps the "misconfigured means 503 for native only" behavior.
docs
- This repo is public, and
docs/native-oauth.mdplus 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. - 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
jwtVerifyacceptstyp: "at+jwt"and normalizes theapplication/prefix and case, so the manualtypcheck atnative-oauth.ts:155can go. Both test files also build theOAUTH_NATIVE_*env separately, with values that have already diverged. Anissuer.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 1Native 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.
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.
…ssuer answers as errors
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
masnwilliams
left a comment
There was a problem hiding this comment.
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:
- 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.
- local dev heads-up. the old
.env.examplesetMANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com, so a.env.localcopied 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. - nit: an
invalid_scopeanswer from the exchange returns the shared 403 copy ("not scoped to the requested Kernel project"), which reads wrong for a scope rejection.

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] endWhat changed and why
typ: at+jwtis 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 withresource=<API origin>, and the exchanged token's lineage is checked. It never falls back to Clerk.subandgrant_id, not on the exchanged token, which changes on every exchange. Before this, app-only tools were denied afterinitialize, every request missed the entitlements cache, and analytics sessions rotated.MANAGED_AUTH_APP_ORIGIN), using the same table discovery uses.resource_name: "KERNEL"andresource_documentation: "https://www.kernel.sh/docs".Configuration
MANAGED_AUTH_APP_ORIGIN, default production)API_BASE_URLmust behttps://mcp.onkernel.comhttps://auth.onkernel.comhttps://api.onkernel.comhttps://mcp.dev.onkernel.comhttps://auth.dev.onkernel.comhttps://api.dev.onkernel.comNATIVE_OAUTH_ISSUERNATIVE_OAUTH_MCP_EXCHANGE_CLIENT_IDNATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRETNATIVE_OAUTH_ISSUERStartup check:
src/instrumentation.ts.next builddoes not run it. Withnext start, a failing check logsFailed to prepare serverand 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)MANAGED_AUTH_APP_ORIGINis that environment's public origin:https://mcp.onkernel.comin Production andhttps://mcp.dev.onkernel.comfor staging. ConfirmAPI_BASE_URLis the matching API. If staging relies on the unset default, it would be treated as production and fail the check.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_IDandNATIVE_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 inNATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET_HASH. Never share a secret across environments.MANAGED_AUTH_APP_ORIGINat a non-Kernel origin without native settings (native off).Activation prerequisites
<mcp origin>/mcpand the exchange client id and secret digest configured.NATIVE_OAUTH_DEFAULT_NEW_FLOWS=trueon the API and run the acceptance list below. Production follows only after staging passes and is approved.Staging acceptance
https://mcp.dev.onkernel.com/.well-known/oauth-protected-resource/mcpreturns:resource: https://mcp.dev.onkernel.com/mcpauthorization_servers: [https://auth.dev.onkernel.com]resource_name: KERNELresource_documentation: https://www.kernel.sh/docstyp: at+jwtandaud: ["https://mcp.dev.onkernel.com/mcp"].initialize,tools/listand a project-scoped tool call succeed, and another project's resource is denied.Tests
jtifixtures. They cover the App gate across rotated tokens, entitlements reuse, and analytics session stability; all three failed on the previous identity.resource, basic-auth encoding, OAuth error mapping, lineage failures, and capping at the signed expiry for issuer clocks ahead of or behind ours.register()rejects invalid settings.subscriptions/listenstream both close at the exchanged token's signed expiry.bunx tsc --noEmit,bun test, Prettier,check:managed-auth-app, andnext buildwithout native configuration.