From 543f57513640b9969d1b56a46e39cce5063557a0 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:56:48 +0000 Subject: [PATCH 01/12] Add gated native OAuth verification and token exchange --- .env.example | 10 ++ src/app/[transport]/route.ts | 59 ++++++++ src/app/authorize/route.ts | 14 +- src/app/register/route.ts | 9 +- src/app/token/route.ts | 22 +++ src/lib/mcp/auth-context.ts | 9 +- src/lib/native-oauth.test.ts | 151 ++++++++++++++++++++ src/lib/native-oauth.ts | 250 +++++++++++++++++++++++++++++++++ src/lib/oauth-compatibility.ts | 18 +++ 9 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 src/lib/native-oauth.test.ts create mode 100644 src/lib/native-oauth.ts create mode 100644 src/lib/oauth-compatibility.ts diff --git a/.env.example b/.env.example index 95f16a49..6e6e6b3e 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,16 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ +# Native reader: enable only after the issuer API has deployed. Discovery is unchanged. +OAUTH_NATIVE_ENABLED=false +OAUTH_NATIVE_ISSUER=https://auth.onkernel.com +OAUTH_NATIVE_MCP_AUDIENCE=https://mcp.onkernel.com +OAUTH_NATIVE_API_AUDIENCE=https://api.onkernel.com/ +OAUTH_NATIVE_ISSUER_EPOCH= +OAUTH_NATIVE_VERIFICATION_JWKS= +OAUTH_NATIVE_EXCHANGE_CLIENT= +OAUTH_NATIVE_EXCHANGE_SECRET= + # OAuth Configuration NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index b7b41ece..d7c35636 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -6,6 +6,13 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; +import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; +import { + isNativeOAuthCredential, + nativeOAuthConfig, + exchangeNativeOAuth, + leaseNativeResponse, +} from "@/lib/native-oauth"; import { OAUTH_RESOURCE_METADATA_PATH, oauthResourceMetadataUrl, @@ -224,6 +231,48 @@ async function handleAuthenticatedRequest( ); } + if (isNativeOAuthCredential(token)) { + const abort = new AbortController(); + try { + const config = nativeOAuthConfig(); + if (!config) return createAuthErrorResponse(req); + const native = await exchangeNativeOAuth(token, req.signal, config); + recordOAuthCompatibility({ + surface: "verification", + provider: "kernel", + outcome: "verified", + }); + const signal = AbortSignal.any([ + req.signal, + abort.signal, + AbortSignal.timeout(Math.max(1, native.deadline - Date.now())), + ]); + const response = await handleMcpRequestWithIdentity({ + req: new NextRequest(req, { signal }), + token: native.token, + authSubject: `native:${native.subject}`, + scopes: native.scopes, + authInfoExtra: { userId: null, clerkToken: null }, + credentialType: "oauth", + transportSessionId, + observeConnection, + }); + return leaseNativeResponse(response, native.deadline, abort); + } catch { + abort.abort(); + recordOAuthCompatibility({ + surface: "verification", + provider: "kernel", + outcome: "rejected", + }); + return createAuthErrorResponse( + req, + "invalid_token", + "Native credential could not be verified", + ); + } + } + if (!isValidJwtFormat(token)) { // Opaque API keys are authenticated by the Kernel API rather than Clerk. // Do not cache their context: /auth/context must revalidate the credential @@ -253,7 +302,17 @@ async function handleAuthenticatedRequest( ); } userId = payload.sub; + recordOAuthCompatibility({ + surface: "verification", + provider: "clerk", + outcome: "verified", + }); } catch (authError) { + recordOAuthCompatibility({ + surface: "verification", + provider: "clerk", + outcome: "rejected", + }); return createAuthErrorResponse( req, "invalid_token", diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts index d4a90092..02263e4c 100644 --- a/src/app/authorize/route.ts +++ b/src/app/authorize/route.ts @@ -1,4 +1,5 @@ import { auth } from "@clerk/nextjs/server"; +import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; import { NextRequest, NextResponse } from "next/server"; import { setAuthorizationContextForClientId, @@ -255,5 +256,16 @@ export async function authorizeRequest( } export async function GET(request: NextRequest): Promise { - return authorizeRequest(request); + if (request.nextUrl.searchParams.get("client_id")?.startsWith("kn_client_")) + return errorResponse( + "invalid_request", + "Native clients are not accepted by the legacy issuer", + ); + const response = await authorizeRequest(request); + recordOAuthCompatibility({ + surface: "authorize", + provider: "clerk", + outcome: response.status < 400 ? "success" : "error", + }); + return response; } diff --git a/src/app/register/route.ts b/src/app/register/route.ts index df19d4fd..97a99142 100644 --- a/src/app/register/route.ts +++ b/src/app/register/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { clerkClient } from "@clerk/nextjs/server"; import { expandLocalhostUris } from "@/lib/auth-utils"; +import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; // Custom registration endpoint needed because Clerk doesn't support custom scopes // We only want "openid" scope instead of Clerk's default email/profile scopes @@ -187,5 +188,11 @@ export async function registerRequest( } export async function POST(request: NextRequest): Promise { - return registerRequest(request); + const response = await registerRequest(request); + recordOAuthCompatibility({ + surface: "register", + provider: "clerk", + outcome: response.ok ? "success" : "error", + }); + return response; } diff --git a/src/app/token/route.ts b/src/app/token/route.ts index 051bd85e..9ecc0fd7 100644 --- a/src/app/token/route.ts +++ b/src/app/token/route.ts @@ -1,4 +1,5 @@ import { clerkClient, verifyToken } from "@clerk/nextjs/server"; +import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; import { after, NextRequest, NextResponse } from "next/server"; import { persistOAuthTokenContexts } from "@/lib/redis"; import { resolveAuthorizationContext } from "@/lib/org-utils"; @@ -425,7 +426,28 @@ export async function tokenRequest( } export async function POST(request: NextRequest): Promise { + const raw = await request.clone().text(); + if (raw.length > 16384) + return createErrorResponse("invalid_request", "Request exceeds limit", 400); + const form = new URLSearchParams(raw); + const native = + form.get("client_id")?.startsWith("kn_client_") || + form.get("code")?.startsWith("krn_ac1_") || + form.get("refresh_token")?.startsWith("krn_rt1_") || + form.get("grant_type") === + "urn:ietf:params:oauth:grant-type:token-exchange"; + if (native) + return createErrorResponse( + "invalid_grant", + "Native credentials are not accepted by the legacy issuer", + 400, + ); const response = await tokenRequest(request); + recordOAuthCompatibility({ + surface: "token", + provider: "clerk", + outcome: response.ok ? "success" : "error", + }); after(flushMcpAnalytics); return response; } diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts index 79b264b6..f70f09be 100644 --- a/src/lib/mcp/auth-context.ts +++ b/src/lib/mcp/auth-context.ts @@ -1,5 +1,5 @@ import { APIConnectionError, APIUserAbortError } from "@onkernel/sdk"; -import type { AuthContext } from "@onkernel/sdk/resources/auth/context"; +import type { AuthContext as SDKAuthContext } from "@onkernel/sdk/resources/auth/context"; import { createHash } from "crypto"; import { z } from "zod"; import { @@ -7,6 +7,11 @@ import { type McpDependencies, } from "@/lib/mcp/dependencies"; +// The additive service principal lands before the generated SDK release. +export type AuthContext = Omit & { + principal: { type: "api_key" | "user" | "service"; id: string }; +}; + const authContextSchema: z.ZodType = z.object({ authentication: z.object({ method: z.enum(["api_key", "jwt"]), @@ -14,7 +19,7 @@ const authContextSchema: z.ZodType = z.object({ credential_id: z.string().nullable(), }), principal: z.object({ - type: z.enum(["api_key", "user"]), + type: z.enum(["api_key", "user", "service"]), id: z.string().min(1), }), organization: z.object({ diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts new file mode 100644 index 00000000..16517967 --- /dev/null +++ b/src/lib/native-oauth.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { generateKeyPair, exportJWK, SignJWT } from "jose"; +import { + exchangeNativeOAuth, + isNativeOAuthCredential, + leaseNativeResponse, + type NativeOAuthConfig, +} from "./native-oauth"; + +async function fixture() { + const keys = await generateKeyPair("RS256"); + const jwk = { + ...(await exportJWK(keys.publicKey)), + alg: "RS256", + use: "sig", + kid: "test", + }; + const config: NativeOAuthConfig = { + issuer: "https://issuer.example", + audience: "https://mcp.example", + apiAudience: "https://api.example/", + epoch: 1, + clientId: "exchange-client", + clientSecret: "test-secret", + keys: { keys: [jwk] }, + }; + const now = Math.floor(Date.now() / 1000); + const claims = { + iss: config.issuer, + aud: [config.audience], + sub: "user_test", + jti: "parent", + iat: now, + exp: now + 300, + client_id: "client", + issuer_epoch: 1, + authorization_version: 1, + scope: "browsers:read", + }; + const token = await new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "test" }) + .sign(keys.privateKey); + const derived = await new SignJWT({ + ...claims, + aud: [config.apiAudience], + jti: "derived", + exp: now + 45, + client_id: config.clientId, + act: { sub: `oauth-client:${config.clientId}` }, + }) + .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "test" }) + .sign(keys.privateKey); + const calls: string[] = []; + const request = async (url: string | URL | Request, init?: RequestInit) => { + calls.push(String(url)); + expect(init?.redirect).toBe("error"); + expect(init?.cache).toBe("no-store"); + if (String(url).endsWith("/introspect")) + return Response.json({ ...claims, active: true }); + const form = new URLSearchParams(String(init?.body)); + expect(form.get("subject_token")).toBe(token); + expect(form.get("resource")).toBe(config.apiAudience); + return Response.json({ + access_token: derived, + token_type: "Bearer", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + expires_in: 45, + scope: claims.scope, + }); + }; + return { config, token, derived, calls, request }; +} + +describe("native OAuth MCP consumer", () => { + test("exchanges rather than passing through, rechecking status on every request", async () => { + const f = await fixture(); + for (let n = 0; n < 2; n++) { + const result = await exchangeNativeOAuth( + f.token, + new AbortController().signal, + f.config, + f.request, + ); + expect(result.token).toBe(f.derived); + expect(result.token).not.toBe(f.token); + expect(result.scopes).toEqual(["browsers:read"]); + } + expect(f.calls.length).toBe(4); + }); + test("rejects wrong audiences and epochs before network access", async () => { + const f = await fixture(); + for (const config of [ + { ...f.config, audience: f.config.apiAudience }, + { ...f.config, epoch: 2 }, + ]) { + await expect( + exchangeNativeOAuth( + f.token, + new AbortController().signal, + config, + f.request, + ), + ).rejects.toThrow(); + } + expect(f.calls).toEqual([]); + }); + test("revocation and authority outages cannot become Clerk fallback", async () => { + const f = await fixture(); + for (const response of [ + Response.json({ active: false }), + new Response(null, { status: 503 }), + ]) { + let calls = 0; + const request = async () => { + calls++; + return response; + }; + await expect( + exchangeNativeOAuth( + f.token, + new AbortController().signal, + f.config, + request, + ), + ).rejects.toThrow(); + expect(calls).toBe(1); + } + expect(isNativeOAuthCredential(f.token)).toBe(true); + expect(isNativeOAuthCredential("krn_rt1_invalid")).toBe(true); + expect(isNativeOAuthCredential("kernel_api_key")).toBe(false); + }); + test("closes a stream when its short authorization lease ends", async () => { + let cancelled = false; + const abort = new AbortController(); + const response = leaseNativeResponse( + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ), + Date.now() + 20, + abort, + ); + const result = await response.body!.getReader().read(); + expect(result.done).toBe(true); + expect(abort.signal.aborted).toBe(true); + expect(cancelled).toBe(true); + }); +}); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts new file mode 100644 index 00000000..2b631535 --- /dev/null +++ b/src/lib/native-oauth.ts @@ -0,0 +1,250 @@ +import { + decodeProtectedHeader, + decodeJwt, + createLocalJWKSet, + jwtVerify, + type JSONWebKeySet, +} from "jose"; +import { z } from "zod"; + +const claimsSchema = z.object({ + active: z.literal(true), + iss: z.string(), + aud: z.array(z.string()).length(1), + sub: z.string().min(1), + jti: z.string().min(1), + client_id: z.string().min(1), + issuer_epoch: z.number().int().positive(), + scope: z.string().min(1), + exp: z.number(), +}); +const exchangeSchema = z.object({ + access_token: z.string().min(1).max(4096), + token_type: z.literal("Bearer"), + issued_token_type: z.literal("urn:ietf:params:oauth:token-type:access_token"), + expires_in: z.number().positive().max(60), + scope: z.string(), +}); + +export function isNativeOAuthCredential(token: string): boolean { + if (token.startsWith("krn_")) return true; + if (token.length > 16384) return false; + try { + const header = decodeProtectedHeader(token); + const claims = decodeJwt(token); + return ( + header.typ === "at+jwt" || + header.typ === "application/at+jwt" || + "issuer_epoch" in claims || + "authorization_version" in claims + ); + } catch { + return false; + } +} + +export type NativeOAuthConfig = { + issuer: string; + audience: string; + apiAudience: string; + epoch: number; + clientId: string; + clientSecret: string; + keys: JSONWebKeySet; +}; + +export function nativeOAuthConfig(): NativeOAuthConfig | null { + if (process.env.OAUTH_NATIVE_ENABLED !== "true") return null; + const issuer = process.env.OAUTH_NATIVE_ISSUER ?? "https://auth.onkernel.com"; + const parsed = new URL(issuer); + if (parsed.protocol !== "https:" || parsed.origin !== issuer) + throw new Error("invalid native OAuth issuer"); + const clientId = process.env.OAUTH_NATIVE_EXCHANGE_CLIENT; + const clientSecret = process.env.OAUTH_NATIVE_EXCHANGE_SECRET; + const epoch = Number(process.env.OAUTH_NATIVE_ISSUER_EPOCH); + if (!clientId || !clientSecret || !Number.isSafeInteger(epoch) || epoch < 1) + throw new Error("incomplete native OAuth configuration"); + return { + issuer, + audience: + process.env.OAUTH_NATIVE_MCP_AUDIENCE ?? "https://mcp.onkernel.com", + apiAudience: + process.env.OAUTH_NATIVE_API_AUDIENCE ?? "https://api.onkernel.com/", + epoch, + clientId, + clientSecret, + keys: JSON.parse(process.env.OAUTH_NATIVE_VERIFICATION_JWKS ?? "{}"), + }; +} + +// All HTTP destinations and verification keys come from server configuration, never JWT claims. +export async function exchangeNativeOAuth( + token: string, + signal: AbortSignal, + config: NativeOAuthConfig, + request: (url: string, init: RequestInit) => Promise = fetch, +) { + if (token.length > 4096) throw new Error("invalid native credential"); + const verified = await jwtVerify(token, createLocalJWKSet(config.keys), { + issuer: config.issuer, + audience: config.audience, + algorithms: ["RS256"], + requiredClaims: [ + "sub", + "jti", + "iat", + "exp", + "client_id", + "issuer_epoch", + "scope", + ], + }); + if ( + !["at+jwt", "application/at+jwt"].includes( + verified.protectedHeader.typ ?? "", + ) || + verified.payload.issuer_epoch !== config.epoch || + !Array.isArray(verified.payload.aud) || + verified.payload.aud.length !== 1 || + typeof verified.payload.iat !== "number" || + typeof verified.payload.exp !== "number" || + verified.payload.exp - verified.payload.iat > 600 || + verified.payload.act + ) + throw new Error("invalid native credential"); + const authorization = `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`; + const post = async (path: string, form: URLSearchParams) => { + const response = await request(`${config.issuer}${path}`, { + method: "POST", + headers: { + Authorization: authorization, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: form, + cache: "no-store", + redirect: "error", + signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), + }); + if (!response.ok) + throw new Error( + "native OAuth authority unavailable or credential rejected", + ); + const text = await response.text(); + if (text.length > 16384) throw new Error("invalid native OAuth response"); + return JSON.parse(text) as unknown; + }; + const status = claimsSchema.parse( + await post("/oauth/native/introspect", new URLSearchParams({ token })), + ); + if ( + status.iss !== config.issuer || + status.aud[0] !== config.audience || + status.issuer_epoch !== config.epoch || + status.sub !== verified.payload.sub || + status.jti !== verified.payload.jti || + status.client_id !== verified.payload.client_id || + status.scope !== verified.payload.scope || + status.exp !== verified.payload.exp || + status.exp * 1000 <= Date.now() + ) + throw new Error("inactive native credential"); + const exchanged = exchangeSchema.parse( + await post( + "/token", + new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: token, + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + resource: config.apiAudience, + scope: status.scope, + }), + ), + ); + const derived = await jwtVerify( + exchanged.access_token, + createLocalJWKSet(config.keys), + { + issuer: config.issuer, + audience: config.apiAudience, + algorithms: ["RS256"], + requiredClaims: ["exp", "sub", "client_id", "scope", "issuer_epoch"], + }, + ); + if ( + derived.payload.sub !== status.sub || + derived.payload.client_id !== config.clientId || + derived.payload.issuer_epoch !== config.epoch || + typeof derived.payload.exp !== "number" || + derived.payload.exp > status.exp || + derived.payload.exp * 1000 > Date.now() + 60000 || + exchanged.scope + .split(" ") + .some((scope) => !status.scope.split(" ").includes(scope)) + ) + throw new Error("invalid native exchange"); + return { + token: exchanged.access_token, + subject: status.sub, + scopes: exchanged.scope.split(" "), + deadline: derived.payload.exp * 1000, + }; +} + +// Native streams have a one-minute maximum lease; reconnect requires fresh authorization. +export function leaseNativeResponse( + response: Response, + deadline: number, + abort: AbortController, +): Response { + if (!response.body) { + abort.abort(); + return response; + } + const reader = response.body.getReader(); + let finished = false; + let timer: ReturnType; + const stream = new ReadableStream({ + start(controller) { + timer = setTimeout( + () => { + if (finished) return; + finished = true; + abort.abort(); + void reader.cancel().catch(() => {}); + controller.close(); + }, + Math.max(0, deadline - Date.now()), + ); + }, + async pull(controller) { + try { + const result = await reader.read(); + if (finished) return; + if (result.done) { + finished = true; + clearTimeout(timer); + abort.abort(); + controller.close(); + } else controller.enqueue(result.value); + } catch (error) { + if (finished) return; + finished = true; + clearTimeout(timer); + abort.abort(); + controller.error(error); + } + }, + async cancel(reason) { + finished = true; + clearTimeout(timer); + abort.abort(); + await reader.cancel(reason); + }, + }); + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} diff --git a/src/lib/oauth-compatibility.ts b/src/lib/oauth-compatibility.ts new file mode 100644 index 00000000..3ff28c7d --- /dev/null +++ b/src/lib/oauth-compatibility.ts @@ -0,0 +1,18 @@ +type CompatibilityEvent = { + surface: "verification" | "token" | "authorize" | "register"; + provider: "clerk" | "kernel"; + outcome: "verified" | "rejected" | "success" | "error"; +}; + +// Categorical events deliberately exclude identities, tokens, URLs and request bodies. +export function recordOAuthCompatibility(event: CompatibilityEvent): void { + console.info( + JSON.stringify({ + event: "oauth_compatibility", + version: 1, + issuer_alias: event.provider === "kernel" ? "canonical" : "legacy_mcp", + cohort: event.provider === "kernel" ? "test" : "unknown", + ...event, + }), + ); +} From 800fb445288f1e38f39f19ce0f2087d6d27ce794 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:21:26 +0000 Subject: [PATCH 02/12] Distinguish native credential rejection from server failures --- src/app/[transport]/route.test.ts | 94 ++++++++++++++++++++++++++++++- src/app/[transport]/route.ts | 50 +++++++++------- src/lib/native-oauth.ts | 47 ++++++++++++---- src/lib/oauth-compatibility.ts | 2 +- 4 files changed, 161 insertions(+), 32 deletions(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 14793cc4..19788aa1 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import * as nativeOAuth from "@/lib/native-oauth"; import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; @@ -73,6 +82,89 @@ afterEach(() => { defaultMcpDependencies.createKernelClient = originalCreateKernelClient; }); +describe("native failure classification", () => { + const config = { + issuer: "https://issuer.example", + audience: "https://mcp.example", + apiAudience: "https://api.example/", + epoch: 1, + clientId: "test-client", + clientSecret: "test-secret", + keys: { keys: [] }, + }; + const request = () => + new nextServer.NextRequest("https://mcp.example/mcp", { + headers: { Authorization: "Bearer krn_invalid" }, + }); + test("configuration and authority failures return retryable 503", async () => { + const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( + config, + ); + const exchangeSpy = spyOn( + nativeOAuth, + "exchangeNativeOAuth", + ).mockRejectedValue(new Error("authority unavailable")); + try { + let response = await GET(request()); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("1"); + configSpy.mockImplementation(() => { + throw new Error("configuration unavailable"); + }); + response = await GET(request()); + expect(response.status).toBe(503); + } finally { + configSpy.mockRestore(); + exchangeSpy.mockRestore(); + } + }); + test("credential rejection remains 401", async () => { + const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( + config, + ); + const exchangeSpy = spyOn( + nativeOAuth, + "exchangeNativeOAuth", + ).mockRejectedValue(new nativeOAuth.NativeCredentialRejected()); + try { + expect((await GET(request())).status).toBe(401); + } finally { + configSpy.mockRestore(); + exchangeSpy.mockRestore(); + } + }); + test("post-authentication bugs propagate without recording a second auth result", async () => { + const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( + config, + ); + const exchangeSpy = spyOn( + nativeOAuth, + "exchangeNativeOAuth", + ).mockResolvedValue({ + token: "derived-test", + subject: "subject", + scopes: ["browsers:read"], + deadline: Date.now() + 30000, + }); + const info = spyOn(console, "info").mockImplementation(() => {}); + failingAuthContext(new Error("unaccountable")); + try { + await expect(GET(request())).rejects.toThrow( + "Unable to resolve Kernel connection scope", + ); + const events = info.mock.calls + .map((call) => String(call[0])) + .filter((value) => value.includes('"event":"oauth_compatibility"')); + expect(events).toHaveLength(1); + expect(events[0]).toContain('"outcome":"verified"'); + } finally { + configSpy.mockRestore(); + exchangeSpy.mockRestore(); + info.mockRestore(); + } + }); +}); + describe("unauthenticated discovery", () => { for (const method of ["GET", "POST"]) { test.each([ diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index d7c35636..5a30a9a1 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -12,6 +12,7 @@ import { nativeOAuthConfig, exchangeNativeOAuth, leaseNativeResponse, + NativeCredentialRejected, } from "@/lib/native-oauth"; import { OAUTH_RESOURCE_METADATA_PATH, @@ -232,21 +233,39 @@ async function handleAuthenticatedRequest( } if (isNativeOAuthCredential(token)) { - const abort = new AbortController(); + let native: Awaited>; try { const config = nativeOAuthConfig(); - if (!config) return createAuthErrorResponse(req); - const native = await exchangeNativeOAuth(token, req.signal, config); + if (!config) throw new NativeCredentialRejected(); + native = await exchangeNativeOAuth(token, req.signal, config); + } catch (error) { + const rejected = error instanceof NativeCredentialRejected; recordOAuthCompatibility({ surface: "verification", provider: "kernel", - outcome: "verified", + outcome: rejected ? "rejected" : "unavailable", }); - const signal = AbortSignal.any([ - req.signal, - abort.signal, - AbortSignal.timeout(Math.max(1, native.deadline - Date.now())), - ]); + return rejected + ? createAuthErrorResponse(req) + : errorResponse( + 503, + "temporarily_unavailable", + "Native authorization is unavailable", + { "Retry-After": "1" }, + ); + } + recordOAuthCompatibility({ + surface: "verification", + provider: "kernel", + outcome: "verified", + }); + const abort = new AbortController(); + const signal = AbortSignal.any([ + req.signal, + abort.signal, + AbortSignal.timeout(Math.max(1, native.deadline - Date.now())), + ]); + try { const response = await handleMcpRequestWithIdentity({ req: new NextRequest(req, { signal }), token: native.token, @@ -258,18 +277,9 @@ async function handleAuthenticatedRequest( observeConnection, }); return leaseNativeResponse(response, native.deadline, abort); - } catch { + } catch (error) { abort.abort(); - recordOAuthCompatibility({ - surface: "verification", - provider: "kernel", - outcome: "rejected", - }); - return createAuthErrorResponse( - req, - "invalid_token", - "Native credential could not be verified", - ); + throw error; } } diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 2b631535..34f144d4 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -43,6 +43,12 @@ export function isNativeOAuthCredential(token: string): boolean { } } +export class NativeCredentialRejected extends Error { + constructor() { + super("native credential rejected"); + } +} + export type NativeOAuthConfig = { issuer: string; audience: string; @@ -84,7 +90,7 @@ export async function exchangeNativeOAuth( config: NativeOAuthConfig, request: (url: string, init: RequestInit) => Promise = fetch, ) { - if (token.length > 4096) throw new Error("invalid native credential"); + if (token.length > 4096) throw new NativeCredentialRejected(); const verified = await jwtVerify(token, createLocalJWKSet(config.keys), { issuer: config.issuer, audience: config.audience, @@ -98,6 +104,8 @@ export async function exchangeNativeOAuth( "issuer_epoch", "scope", ], + }).catch(() => { + throw new NativeCredentialRejected(); }); if ( !["at+jwt", "application/at+jwt"].includes( @@ -111,7 +119,7 @@ export async function exchangeNativeOAuth( verified.payload.exp - verified.payload.iat > 600 || verified.payload.act ) - throw new Error("invalid native credential"); + throw new NativeCredentialRejected(); const authorization = `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`; const post = async (path: string, form: URLSearchParams) => { const response = await request(`${config.issuer}${path}`, { @@ -125,17 +133,30 @@ export async function exchangeNativeOAuth( redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), }); - if (!response.ok) - throw new Error( - "native OAuth authority unavailable or credential rejected", - ); + if (response.status >= 500) + throw new Error("native OAuth authority unavailable"); const text = await response.text(); if (text.length > 16384) throw new Error("invalid native OAuth response"); - return JSON.parse(text) as unknown; + const body: unknown = JSON.parse(text); + if (!response.ok) { + const failure = z.object({ error: z.string() }).safeParse(body); + if ( + response.status === 400 && + failure.success && + failure.data.error === "invalid_grant" + ) + throw new NativeCredentialRejected(); + throw new Error("native OAuth authority request failed"); + } + return body; }; - const status = claimsSchema.parse( - await post("/oauth/native/introspect", new URLSearchParams({ token })), + const introspection = await post( + "/oauth/native/introspect", + new URLSearchParams({ token }), ); + if (z.object({ active: z.literal(false) }).safeParse(introspection).success) + throw new NativeCredentialRejected(); + const status = claimsSchema.parse(introspection); if ( status.iss !== config.issuer || status.aud[0] !== config.audience || @@ -147,7 +168,7 @@ export async function exchangeNativeOAuth( status.exp !== verified.payload.exp || status.exp * 1000 <= Date.now() ) - throw new Error("inactive native credential"); + throw new NativeCredentialRejected(); const exchanged = exchangeSchema.parse( await post( "/token", @@ -175,6 +196,12 @@ export async function exchangeNativeOAuth( derived.payload.sub !== status.sub || derived.payload.client_id !== config.clientId || derived.payload.issuer_epoch !== config.epoch || + derived.payload.scope !== exchanged.scope || + !["at+jwt", "application/at+jwt"].includes( + derived.protectedHeader.typ ?? "", + ) || + !Array.isArray(derived.payload.aud) || + derived.payload.aud.length !== 1 || typeof derived.payload.exp !== "number" || derived.payload.exp > status.exp || derived.payload.exp * 1000 > Date.now() + 60000 || diff --git a/src/lib/oauth-compatibility.ts b/src/lib/oauth-compatibility.ts index 3ff28c7d..f6237f31 100644 --- a/src/lib/oauth-compatibility.ts +++ b/src/lib/oauth-compatibility.ts @@ -1,7 +1,7 @@ type CompatibilityEvent = { surface: "verification" | "token" | "authorize" | "register"; provider: "clerk" | "kernel"; - outcome: "verified" | "rejected" | "success" | "error"; + outcome: "verified" | "rejected" | "success" | "error" | "unavailable"; }; // Categorical events deliberately exclude identities, tokens, URLs and request bodies. From 2caf66edbc6c9a9a0ff52b9cfb7a0580db3fdb57 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:36:38 +0000 Subject: [PATCH 03/12] Bound native OAuth leases independently of clock skew --- src/lib/native-oauth.test.ts | 16 ++++++++++++++-- src/lib/native-oauth.ts | 26 ++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 16517967..abb120b1 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -7,7 +7,7 @@ import { type NativeOAuthConfig, } from "./native-oauth"; -async function fixture() { +async function fixture(clockOffsetSeconds = 0) { const keys = await generateKeyPair("RS256"); const jwk = { ...(await exportJWK(keys.publicKey)), @@ -24,7 +24,7 @@ async function fixture() { clientSecret: "test-secret", keys: { keys: [jwk] }, }; - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000) + clockOffsetSeconds; const claims = { iss: config.issuer, aud: [config.audience], @@ -87,6 +87,18 @@ describe("native OAuth MCP consumer", () => { } expect(f.calls.length).toBe(4); }); + test("bounds the lease by relative lifetime despite issuer clock skew", async () => { + const f = await fixture(20); + const started = Date.now(); + const result = await exchangeNativeOAuth( + f.token, + new AbortController().signal, + f.config, + f.request, + ); + expect(result.deadline).toBeGreaterThan(started + 44000); + expect(result.deadline).toBeLessThanOrEqual(Date.now() + 45000); + }); test("rejects wrong audiences and epochs before network access", async () => { const f = await fixture(); for (const config of [ diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 34f144d4..54ca3fb8 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -95,6 +95,7 @@ export async function exchangeNativeOAuth( issuer: config.issuer, audience: config.audience, algorithms: ["RS256"], + clockTolerance: 30, requiredClaims: [ "sub", "jti", @@ -166,9 +167,10 @@ export async function exchangeNativeOAuth( status.client_id !== verified.payload.client_id || status.scope !== verified.payload.scope || status.exp !== verified.payload.exp || - status.exp * 1000 <= Date.now() + status.exp * 1000 + 30000 <= Date.now() ) throw new NativeCredentialRejected(); + const exchangeStartedAt = Date.now(); const exchanged = exchangeSchema.parse( await post( "/token", @@ -189,7 +191,15 @@ export async function exchangeNativeOAuth( issuer: config.issuer, audience: config.apiAudience, algorithms: ["RS256"], - requiredClaims: ["exp", "sub", "client_id", "scope", "issuer_epoch"], + clockTolerance: 30, + requiredClaims: [ + "iat", + "exp", + "sub", + "client_id", + "scope", + "issuer_epoch", + ], }, ); if ( @@ -204,7 +214,9 @@ export async function exchangeNativeOAuth( derived.payload.aud.length !== 1 || typeof derived.payload.exp !== "number" || derived.payload.exp > status.exp || - derived.payload.exp * 1000 > Date.now() + 60000 || + typeof derived.payload.iat !== "number" || + derived.payload.exp - derived.payload.iat > 60 || + derived.payload.exp <= derived.payload.iat || exchanged.scope .split(" ") .some((scope) => !status.scope.split(" ").includes(scope)) @@ -214,7 +226,13 @@ export async function exchangeNativeOAuth( token: exchanged.access_token, subject: status.sub, scopes: exchanged.scope.split(" "), - deadline: derived.payload.exp * 1000, + deadline: + exchangeStartedAt + + Math.min( + exchanged.expires_in, + derived.payload.exp - derived.payload.iat, + ) * + 1000, }; } From 357311f9948a1cf64a7e41f760d21969519561b3 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:49:37 +0000 Subject: [PATCH 04/12] Return terminal scope errors for rejected OAuth exchange --- src/app/[transport]/route.test.ts | 19 +++++++++++++++++++ src/app/[transport]/route.ts | 12 +++++++++++- src/lib/native-oauth.test.ts | 18 ++++++++++++++++++ src/lib/native-oauth.ts | 12 ++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 19788aa1..1e3aa3ff 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -133,6 +133,25 @@ describe("native failure classification", () => { exchangeSpy.mockRestore(); } }); + test("a permanent scope mismatch is 403 rather than a retryable outage", async () => { + const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( + config, + ); + const exchangeSpy = spyOn( + nativeOAuth, + "exchangeNativeOAuth", + ).mockRejectedValue(new nativeOAuth.NativeScopeRejected()); + try { + const response = await GET(request()); + expect(response.status).toBe(403); + expect(response.headers.get("WWW-Authenticate")).toContain( + "insufficient_scope", + ); + } finally { + configSpy.mockRestore(); + exchangeSpy.mockRestore(); + } + }); test("post-authentication bugs propagate without recording a second auth result", async () => { const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( config, diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 5a30a9a1..defd3217 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -13,6 +13,7 @@ import { exchangeNativeOAuth, leaseNativeResponse, NativeCredentialRejected, + NativeScopeRejected, } from "@/lib/native-oauth"; import { OAUTH_RESOURCE_METADATA_PATH, @@ -239,12 +240,21 @@ async function handleAuthenticatedRequest( if (!config) throw new NativeCredentialRejected(); native = await exchangeNativeOAuth(token, req.signal, config); } catch (error) { - const rejected = error instanceof NativeCredentialRejected; + const insufficient = error instanceof NativeScopeRejected; + const rejected = + insufficient || error instanceof NativeCredentialRejected; recordOAuthCompatibility({ surface: "verification", provider: "kernel", outcome: rejected ? "rejected" : "unavailable", }); + if (insufficient) + return errorResponse( + 403, + "insufficient_scope", + "This credential cannot access the requested resource", + { "WWW-Authenticate": 'Bearer error="insufficient_scope"' }, + ); return rejected ? createAuthErrorResponse(req) : errorResponse( diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index abb120b1..233d951b 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -4,6 +4,7 @@ import { exchangeNativeOAuth, isNativeOAuthCredential, leaseNativeResponse, + NativeScopeRejected, type NativeOAuthConfig, } from "./native-oauth"; @@ -141,6 +142,23 @@ describe("native OAuth MCP consumer", () => { expect(isNativeOAuthCredential("krn_rt1_invalid")).toBe(true); expect(isNativeOAuthCredential("kernel_api_key")).toBe(false); }); + test("treats issuer scope and target rejection as permanent scope failures", async () => { + for (const error of ["invalid_scope", "invalid_target"]) { + const f = await fixture(); + const request = async (url: string, init: RequestInit) => + url.endsWith("/token") + ? Response.json({ error }, { status: 400 }) + : f.request(url, init); + await expect( + exchangeNativeOAuth( + f.token, + new AbortController().signal, + f.config, + request, + ), + ).rejects.toBeInstanceOf(NativeScopeRejected); + } + }); test("closes a stream when its short authorization lease ends", async () => { let cancelled = false; const abort = new AbortController(); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 54ca3fb8..00c6e4d4 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -49,6 +49,12 @@ export class NativeCredentialRejected extends Error { } } +export class NativeScopeRejected extends Error { + constructor() { + super("native scope or resource rejected"); + } +} + export type NativeOAuthConfig = { issuer: string; audience: string; @@ -147,6 +153,12 @@ export async function exchangeNativeOAuth( failure.data.error === "invalid_grant" ) throw new NativeCredentialRejected(); + if ( + response.status === 400 && + failure.success && + ["invalid_scope", "invalid_target"].includes(failure.data.error) + ) + throw new NativeScopeRejected(); throw new Error("native OAuth authority request failed"); } return body; From 36f63002f1a258fec044df6e72a33385d95acbbc Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:27:46 +0000 Subject: [PATCH 05/12] Align native OAuth consumer with the deployed issuer contract 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. --- .env.example | 19 +- docs/native-oauth.md | 112 ++++++ src/app/[transport]/route.test.ts | 234 ++++++++----- src/app/[transport]/route.ts | 28 +- src/app/authorize/route.ts | 14 +- src/app/register/route.ts | 9 +- src/app/token/route.ts | 22 -- src/lib/mcp/auth-context.ts | 9 +- src/lib/native-oauth.test-fixtures.ts | 140 ++++++++ src/lib/native-oauth.test.ts | 469 ++++++++++++++++++-------- src/lib/native-oauth.ts | 345 +++++++++---------- src/lib/oauth-compatibility.ts | 18 - 12 files changed, 911 insertions(+), 508 deletions(-) create mode 100644 docs/native-oauth.md create mode 100644 src/lib/native-oauth.test-fixtures.ts delete mode 100644 src/lib/oauth-compatibility.ts diff --git a/.env.example b/.env.example index 6e6e6b3e..4d9b10bd 100644 --- a/.env.example +++ b/.env.example @@ -7,15 +7,16 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ -# Native reader: enable only after the issuer API has deployed. Discovery is unchanged. -OAUTH_NATIVE_ENABLED=false -OAUTH_NATIVE_ISSUER=https://auth.onkernel.com -OAUTH_NATIVE_MCP_AUDIENCE=https://mcp.onkernel.com -OAUTH_NATIVE_API_AUDIENCE=https://api.onkernel.com/ -OAUTH_NATIVE_ISSUER_EPOCH= -OAUTH_NATIVE_VERIFICATION_JWKS= -OAUTH_NATIVE_EXCHANGE_CLIENT= -OAUTH_NATIVE_EXCHANGE_SECRET= +# Kernel-issued (native) OAuth consumer. Off unless OAUTH_NATIVE_ENABLED=true; +# when enabled every value below is required. See docs/native-oauth.md. +# OAUTH_NATIVE_ENABLED=false +# OAUTH_NATIVE_ISSUER=https://auth.dev.onkernel.com +# OAUTH_NATIVE_MCP_AUDIENCE=https://mcp.dev.onkernel.com +# OAUTH_NATIVE_API_AUDIENCE=https://api.dev.onkernel.com +# OAUTH_NATIVE_ISSUER_EPOCH=1 +# OAUTH_NATIVE_VERIFICATION_JWKS={"keys":[...]} +# OAUTH_NATIVE_EXCHANGE_CLIENT=kcl_ +# OAUTH_NATIVE_EXCHANGE_SECRET=kcs_ # OAuth Configuration NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev diff --git a/docs/native-oauth.md b/docs/native-oauth.md new file mode 100644 index 00000000..fbdcdf7d --- /dev/null +++ b/docs/native-oauth.md @@ -0,0 +1,112 @@ +# Kernel-issued (native) OAuth consumer + +The MCP server can accept access tokens issued by the Kernel API's native +issuer (`packages/api/lib/oauthnative` in kernel/kernel). It is off by default. +With `OAUTH_NATIVE_ENABLED` unset or not `true`, nothing else is read and no +new secret is required. + +## Behavior + +A bearer token is native when it is a compact JWT whose header `typ` is +`at+jwt` or `application/at+jwt`, the same selector the Kernel API uses. +Clerk session tokens (`typ: JWT`) and opaque API keys keep their existing +paths. A native token never falls back to Clerk or to API-key handling. + +| State | Native token | +| -------------------------------- | ----------------------------------------------------------- | +| disabled | 401 `invalid_token`, no issuer or API call | +| enabled, configuration invalid | 503 `temporarily_unavailable` | +| signature/claims rejected | 401 before any network call | +| issuer answers `invalid_grant` | 401 (revoked, expired, inactive grant) | +| issuer answers `invalid_scope` | 403 `insufficient_scope` | +| any other issuer failure | 503 `temporarily_unavailable` | +| exchanged token fails validation | 503 `temporarily_unavailable` | +| exchange succeeds | request proceeds with the exchanged token, one-minute lease | + +For every request the server: + +1. Verifies the token locally against the pinned JWKS: RS256, `kid`, issuer, + exactly one audience equal to `OAUTH_NATIVE_MCP_AUDIENCE`, issuer epoch, + `authorization_version` 1, lifetime at most ten minutes, 30 s skew, and + no `act`/`parent_jti` (exchanged tokens cannot be exchanged again). +2. Exchanges it at `/token` (RFC 8693, `client_secret_basic` with the + configured intermediary) for an API-audience token with the parent's + scope. The issuer re-checks grant, registration and membership status on + every exchange, so revocation takes effect on the next request. +3. Verifies the exchanged token: API audience, at most five minutes, not + outliving the parent, same `sub`/`grant_id`/`org_id`/`project_id`, + `client_id` and `act.sub` naming the intermediary, `parent_jti` equal to + the parent `jti`, and scopes no wider than the parent's. +4. Forwards only the exchanged token to the Kernel API. The MCP-audience + token is never sent to the API. + +The request and any stream it opens end after one minute or the exchanged +token's remaining lifetime, whichever is shorter; continuing needs a new +request and a new exchange. Each native request therefore costs one exchange +(one issued-token row) on the API. + +## Configuration + +Every value is required when `OAUTH_NATIVE_ENABLED=true`; none has a default. + +| Variable | Staging value | +| -------------------------------- | ---------------------------------------------------------------------- | +| `OAUTH_NATIVE_ISSUER` | `https://auth.dev.onkernel.com` (API `iss`) | +| `OAUTH_NATIVE_MCP_AUDIENCE` | `https://mcp.dev.onkernel.com` (API `NATIVE_OAUTH_MCP_AUDIENCE`) | +| `OAUTH_NATIVE_API_AUDIENCE` | `https://api.dev.onkernel.com` (API `NATIVE_OAUTH_API_AUDIENCE`) | +| `OAUTH_NATIVE_ISSUER_EPOCH` | API `NATIVE_OAUTH_ISSUER_EPOCH` | +| `OAUTH_NATIVE_VERIFICATION_JWKS` | public keys from `https://auth.dev.onkernel.com/.well-known/jwks.json` | +| `OAUTH_NATIVE_EXCHANGE_CLIENT` | confidential registration id (`kcl_…`) | +| `OAUTH_NATIVE_EXCHANGE_SECRET` | its secret (`kcs_…`), staging secret store only | + +Origins must be HTTPS with no path or trailing slash, and the two audiences +must differ. The JWKS must hold only public RSA members with a `kid`. +Keys are pinned rather than fetched, so the API's signing-key rotation +runbook must update this value in its pre-publish step, and keep retired keys +until no token they signed can still be live. `API_BASE_URL` must point at the +API whose native audience is `OAUTH_NATIVE_API_AUDIENCE`. + +## Activation prerequisites + +Do not enable this anywhere until each item is resolved; production stays off. + +1. **Auth context in policy mode.** Exchanged tokens always run in policy + mode, and every MCP request resolves `GET /auth/context`. On API main that + operation is denied in policy mode, so every native request ends in 403. + kernel/kernel#5309 permits it and must be deployed first. +2. **Tool coverage.** Exchanged tokens reach only the operations listed in + the API's `lib/middleware/scoped_operations.go`; other tools answer 403. + This is not native tool parity with Clerk or API-key callers. +3. **Resource alignment.** MCP discovery advertises the resource + `https://mcp.dev.onkernel.com/mcp`, which MCP clients send as `resource`. + The API accepts only the exact origin `https://mcp.dev.onkernel.com` and + answers `invalid_target` otherwise; a request with no `resource` yields an + API-audience token that this server rejects. One side must accept the + other's identifier deliberately; this server does not rewrite audiences. +4. **Routing.** Staging routes only the CLI natively + (`NATIVE_OAUTH_DEFAULT_NEW_FLOWS=false`), and CLI tokens carry the API + audience. MCP clients receive native MCP-audience tokens only once new + non-CLI flows are routed natively, which also moves every other staging + client. Enable this consumer on staging before that switch. +5. **Intermediary registration.** An operator creates a confidential + registration with only the token-exchange grant + (`Service.CreateConfidentialClient`; there is no HTTP enrollment), sets the + API's `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` to its id, and stores its + secret only in the staging MCP environment. + +## Staging acceptance (not yet run) + +Unit tests cover the contract with locally signed tokens and a mocked issuer +modeled on the deployed API. None of the following has been run: + +1. Fetch protected-resource and authorization-server metadata; register a + public client. +2. Browser PKCE login: Clerk sign-in, organization/project picker, native + consent; redeem the code for an MCP-audience token. +3. `initialize` and `tools/list`; confirm the API sees only the exchanged + token (`act`, `parent_jti`). +4. Call a project-scoped tool that reaches a scoped operation, such as a + browser list, and confirm a different project's resource is denied. +5. Refresh, then revoke the token and the grant; the next request is 401. +6. Hold a stream past one minute; it closes and the client reconnects. +7. Confirm Clerk OAuth and API-key connections behave as before. diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 394bed29..3183bd2b 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -7,7 +7,8 @@ import { spyOn, test, } from "bun:test"; -import * as nativeOAuth from "@/lib/native-oauth"; +import { decodeJwt } from "jose"; +import { nativeIssuer } from "@/lib/native-oauth.test-fixtures"; import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; @@ -84,105 +85,156 @@ afterEach(() => { defaultMcpDependencies.createKernelClient = originalCreateKernelClient; }); -describe("native failure classification", () => { - const config = { - issuer: "https://issuer.example", - audience: "https://mcp.example", - apiAudience: "https://api.example/", - epoch: 1, - clientId: "test-client", - clientSecret: "test-secret", - keys: { keys: [] }, - }; - const request = () => - new nextServer.NextRequest("https://mcp.example/mcp", { - headers: { Authorization: "Bearer krn_invalid" }, - }); - test("configuration and authority failures return retryable 503", async () => { - const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( - config, - ); - const exchangeSpy = spyOn( - nativeOAuth, - "exchangeNativeOAuth", - ).mockRejectedValue(new Error("authority unavailable")); - try { - let response = await GET(request()); - expect(response.status).toBe(503); - expect(response.headers.get("Retry-After")).toBe("1"); - configSpy.mockImplementation(() => { - throw new Error("configuration unavailable"); +describe("native OAuth credentials", () => { + const nativeEnv = Object.keys(process.env).filter((key) => + key.startsWith("OAUTH_NATIVE_"), + ); + let issuer: Awaited>; + let kernelTokens: string[]; + let fetchSpy: ReturnType>; + + beforeEach(async () => { + issuer = await nativeIssuer(); + kernelTokens = []; + defaultMcpDependencies.createKernelClient = (token) => { + kernelTokens.push(token); + return new Kernel({ + apiKey: token, + baseURL: "https://api.example.test", + maxRetries: 0, + fetch: async (input) => { + const path = new URL(String(input)).pathname; + if (path !== "/auth/context") + throw new Error(`Unexpected API request: ${path}`); + // GET /auth/context as the API answers an exchanged user token. + return Response.json({ + authentication: { + method: "jwt", + source: "oauth", + credential_id: null, + }, + principal: { type: "user", id: "user_123" }, + organization: { id: "org_123" }, + authorization: { + credential_scope: { project_id: "proj_123" }, + effective_scope: { project_id: "proj_123" }, + }, + }); + }, }); - response = await GET(request()); - expect(response.status).toBe(503); - } finally { - configSpy.mockRestore(); - exchangeSpy.mockRestore(); - } + }; + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async ( + input: string | URL | Request, + init?: RequestInit, + ) => issuer.request(String(input), init ?? {})) as typeof fetch); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + for (const key of Object.keys(process.env)) + if (key.startsWith("OAUTH_NATIVE_") && !nativeEnv.includes(key)) + delete process.env[key]; }); - test("credential rejection remains 401", async () => { - const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( - config, + + function enable() { + Object.assign(process.env, { + OAUTH_NATIVE_ENABLED: "true", + OAUTH_NATIVE_ISSUER: issuer.config.issuer, + OAUTH_NATIVE_MCP_AUDIENCE: issuer.config.audience, + OAUTH_NATIVE_API_AUDIENCE: issuer.config.apiAudience, + OAUTH_NATIVE_ISSUER_EPOCH: String(issuer.config.epoch), + OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify(issuer.config.keys), + OAUTH_NATIVE_EXCHANGE_CLIENT: issuer.config.clientId, + OAUTH_NATIVE_EXCHANGE_SECRET: issuer.config.clientSecret, + }); + } + + function mcpRequest(token: string, method = "tools/list") { + return new nextServer.NextRequest("https://mcp.example.test/mcp", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method }), + }); + } + + test("are rejected without contacting the issuer or API while disabled", async () => { + delete process.env.OAUTH_NATIVE_ENABLED; + const response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toContain( + "resource_metadata=", ); - const exchangeSpy = spyOn( - nativeOAuth, - "exchangeNativeOAuth", - ).mockRejectedValue(new nativeOAuth.NativeCredentialRejected()); - try { - expect((await GET(request())).status).toBe(401); - } finally { - configSpy.mockRestore(); - exchangeSpy.mockRestore(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(kernelTokens).toEqual([]); + }); + + test("reach the API only as the exchanged API-audience token", async () => { + enable(); + const parent = await issuer.parent(); + const response = await POST(mcpRequest(parent)); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("manage_browsers"); + expect(issuer.requests).toHaveLength(1); + expect(kernelTokens.length).toBeGreaterThan(0); + for (const token of kernelTokens) { + expect(token).not.toBe(parent); + expect(decodeJwt(token).aud).toEqual([issuer.config.apiAudience]); + expect(decodeJwt(token).parent_jti).toBe("jti_parent"); } }); - test("a permanent scope mismatch is 403 rather than a retryable outage", async () => { - const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( - config, + + test("leave API keys on their existing path when enabled", async () => { + enable(); + const response = await POST(mcpRequest("sk_opaque_key")); + expect(response.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(kernelTokens.length).toBeGreaterThan(0); + expect(kernelTokens.every((token) => token === "sk_opaque_key")).toBe(true); + }); + + test("revocation is 401 and scope rejection 403, never a fallback", async () => { + enable(); + issuer.revoked.add("jti_parent"); + let response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(401); + expect((await response.json()).error).toBe("invalid_token"); + + fetchSpy.mockImplementation((async () => + Response.json({ error: "invalid_scope" }, { status: 400 })) as never); + response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(403); + expect(response.headers.get("WWW-Authenticate")).toContain( + "insufficient_scope", ); - const exchangeSpy = spyOn( - nativeOAuth, - "exchangeNativeOAuth", - ).mockRejectedValue(new nativeOAuth.NativeScopeRejected()); - try { - const response = await GET(request()); - expect(response.status).toBe(403); - expect(response.headers.get("WWW-Authenticate")).toContain( - "insufficient_scope", - ); - } finally { - configSpy.mockRestore(); - exchangeSpy.mockRestore(); - } + expect(kernelTokens).toEqual([]); }); - test("post-authentication bugs propagate without recording a second auth result", async () => { - const configSpy = spyOn(nativeOAuth, "nativeOAuthConfig").mockReturnValue( - config, + + test("issuer outages and misconfiguration are retryable 503s", async () => { + enable(); + fetchSpy.mockImplementation( + (async () => new Response(null, { status: 503 })) as never, ); - const exchangeSpy = spyOn( - nativeOAuth, - "exchangeNativeOAuth", - ).mockResolvedValue({ - token: "derived-test", - subject: "subject", - scopes: ["browsers:read"], - deadline: Date.now() + 30000, - }); - const info = spyOn(console, "info").mockImplementation(() => {}); + let response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("1"); + + process.env.OAUTH_NATIVE_API_AUDIENCE = `${issuer.config.apiAudience}/`; + response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(503); + expect(kernelTokens).toEqual([]); + }); + + test("post-authentication bugs propagate as server errors", async () => { + enable(); failingAuthContext(new Error("unaccountable")); - try { - await expect(GET(request())).rejects.toThrow( - "Unable to resolve Kernel connection scope", - ); - const events = info.mock.calls - .map((call) => String(call[0])) - .filter((value) => value.includes('"event":"oauth_compatibility"')); - expect(events).toHaveLength(1); - expect(events[0]).toContain('"outcome":"verified"'); - } finally { - configSpy.mockRestore(); - exchangeSpy.mockRestore(); - info.mockRestore(); - } + await expect(POST(mcpRequest(await issuer.parent()))).rejects.toThrow( + "Unable to resolve Kernel connection scope", + ); }); }); diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 7e864120..20123c99 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -7,7 +7,6 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; -import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; import { isNativeOAuthCredential, nativeOAuthConfig, @@ -300,22 +299,14 @@ async function handleAuthenticatedRequest( if (!config) throw new NativeCredentialRejected(); native = await exchangeNativeOAuth(token, req.signal, config); } catch (error) { - const insufficient = error instanceof NativeScopeRejected; - const rejected = - insufficient || error instanceof NativeCredentialRejected; - recordOAuthCompatibility({ - surface: "verification", - provider: "kernel", - outcome: rejected ? "rejected" : "unavailable", - }); - if (insufficient) + if (error instanceof NativeScopeRejected) return errorResponse( 403, "insufficient_scope", "This credential cannot access the requested resource", { "WWW-Authenticate": 'Bearer error="insufficient_scope"' }, ); - return rejected + return error instanceof NativeCredentialRejected ? createAuthErrorResponse(req) : errorResponse( 503, @@ -324,11 +315,6 @@ async function handleAuthenticatedRequest( { "Retry-After": "1" }, ); } - recordOAuthCompatibility({ - surface: "verification", - provider: "kernel", - outcome: "verified", - }); const abort = new AbortController(); const signal = AbortSignal.any([ req.signal, @@ -382,17 +368,7 @@ async function handleAuthenticatedRequest( ); } userId = payload.sub; - recordOAuthCompatibility({ - surface: "verification", - provider: "clerk", - outcome: "verified", - }); } catch (authError) { - recordOAuthCompatibility({ - surface: "verification", - provider: "clerk", - outcome: "rejected", - }); return createAuthErrorResponse( req, "invalid_token", diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts index 02263e4c..d4a90092 100644 --- a/src/app/authorize/route.ts +++ b/src/app/authorize/route.ts @@ -1,5 +1,4 @@ import { auth } from "@clerk/nextjs/server"; -import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; import { NextRequest, NextResponse } from "next/server"; import { setAuthorizationContextForClientId, @@ -256,16 +255,5 @@ export async function authorizeRequest( } export async function GET(request: NextRequest): Promise { - if (request.nextUrl.searchParams.get("client_id")?.startsWith("kn_client_")) - return errorResponse( - "invalid_request", - "Native clients are not accepted by the legacy issuer", - ); - const response = await authorizeRequest(request); - recordOAuthCompatibility({ - surface: "authorize", - provider: "clerk", - outcome: response.status < 400 ? "success" : "error", - }); - return response; + return authorizeRequest(request); } diff --git a/src/app/register/route.ts b/src/app/register/route.ts index 97a99142..df19d4fd 100644 --- a/src/app/register/route.ts +++ b/src/app/register/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { clerkClient } from "@clerk/nextjs/server"; import { expandLocalhostUris } from "@/lib/auth-utils"; -import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; // Custom registration endpoint needed because Clerk doesn't support custom scopes // We only want "openid" scope instead of Clerk's default email/profile scopes @@ -188,11 +187,5 @@ export async function registerRequest( } export async function POST(request: NextRequest): Promise { - const response = await registerRequest(request); - recordOAuthCompatibility({ - surface: "register", - provider: "clerk", - outcome: response.ok ? "success" : "error", - }); - return response; + return registerRequest(request); } diff --git a/src/app/token/route.ts b/src/app/token/route.ts index c8e7faf5..f395ba52 100644 --- a/src/app/token/route.ts +++ b/src/app/token/route.ts @@ -1,5 +1,4 @@ import { clerkClient, verifyToken } from "@clerk/nextjs/server"; -import { recordOAuthCompatibility } from "@/lib/oauth-compatibility"; import { after, NextRequest, NextResponse } from "next/server"; import { persistOAuthTokenContexts } from "@/lib/redis"; import { resolveAuthorizationContext } from "@/lib/org-utils"; @@ -462,28 +461,7 @@ export async function tokenRequest( } export async function POST(request: NextRequest): Promise { - const raw = await request.clone().text(); - if (raw.length > 16384) - return createErrorResponse("invalid_request", "Request exceeds limit", 400); - const form = new URLSearchParams(raw); - const native = - form.get("client_id")?.startsWith("kn_client_") || - form.get("code")?.startsWith("krn_ac1_") || - form.get("refresh_token")?.startsWith("krn_rt1_") || - form.get("grant_type") === - "urn:ietf:params:oauth:grant-type:token-exchange"; - if (native) - return createErrorResponse( - "invalid_grant", - "Native credentials are not accepted by the legacy issuer", - 400, - ); const response = await tokenRequest(request); - recordOAuthCompatibility({ - surface: "token", - provider: "clerk", - outcome: response.ok ? "success" : "error", - }); after(flushMcpAnalytics); return response; } diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts index f70f09be..79b264b6 100644 --- a/src/lib/mcp/auth-context.ts +++ b/src/lib/mcp/auth-context.ts @@ -1,5 +1,5 @@ import { APIConnectionError, APIUserAbortError } from "@onkernel/sdk"; -import type { AuthContext as SDKAuthContext } from "@onkernel/sdk/resources/auth/context"; +import type { AuthContext } from "@onkernel/sdk/resources/auth/context"; import { createHash } from "crypto"; import { z } from "zod"; import { @@ -7,11 +7,6 @@ import { type McpDependencies, } from "@/lib/mcp/dependencies"; -// The additive service principal lands before the generated SDK release. -export type AuthContext = Omit & { - principal: { type: "api_key" | "user" | "service"; id: string }; -}; - const authContextSchema: z.ZodType = z.object({ authentication: z.object({ method: z.enum(["api_key", "jwt"]), @@ -19,7 +14,7 @@ const authContextSchema: z.ZodType = z.object({ credential_id: z.string().nullable(), }), principal: z.object({ - type: z.enum(["api_key", "user", "service"]), + type: z.enum(["api_key", "user"]), id: z.string().min(1), }), organization: z.object({ diff --git a/src/lib/native-oauth.test-fixtures.ts b/src/lib/native-oauth.test-fixtures.ts new file mode 100644 index 00000000..d3f9cc0f --- /dev/null +++ b/src/lib/native-oauth.test-fixtures.ts @@ -0,0 +1,140 @@ +import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from "jose"; +import type { NativeOAuthConfig } from "./native-oauth"; + +const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; + +// Shapes follow the deployed Kernel API issuer (packages/api/lib/oauthnative): +// AccessClaims, Service.Exchange and the RFC 8693 handler in lib/oauthas. +export async function nativeIssuer() { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const kid = "kid-test"; + const config: NativeOAuthConfig = { + issuer: "https://auth.example.test", + audience: "https://mcp.example.test", + apiAudience: "https://api.example.test", + epoch: 1, + clientId: "kcl_mcp", + clientSecret: "secret+/=", + keys: { keys: [{ ...(await exportJWK(publicKey)), kid }] }, + }; + + async function sign( + claims: Record, + header: Record = {}, + ) { + return new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid, ...header }) + .sign(privateKey); + } + + function parentClaims(overrides: Record = {}) { + const now = Math.floor(Date.now() / 1000); + return { + iss: config.issuer, + sub: "user_123", + aud: [config.audience], + exp: now + 600, + iat: now, + jti: "jti_parent", + client_id: "kcl_desktop", + grant_id: "grant_123", + sub_kind: "user", + org_id: "org_123", + project_id: "proj_123", + scope: "openid email", + authorization_version: 1, + issuer_epoch: config.epoch, + grant_authorization_epoch: 1, + registration_security_version: 1, + ...overrides, + }; + } + + const revoked = new Set(); + const requests: { url: string; init: RequestInit }[] = []; + let derivedOverrides: Record = {}; + let responseOverrides: Record = {}; + + // Mirrors Service.Exchange: verify the MCP-audience parent, reject + // exchanged parents and inactive grants, narrow scopes, then sign an + // API-audience token living min(5m, parent remaining lifetime). + async function request(url: string, init: RequestInit): Promise { + requests.push({ url, init }); + const error = (code: string, status = 400) => + Response.json({ error: code }, { status }); + if (url !== `${config.issuer}/token`) return error("not_found", 404); + const basic = `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`; + if (new Headers(init.headers).get("Authorization") !== basic) + return error("invalid_client", 401); + const form = new URLSearchParams(String(init.body)); + if ( + form.get("grant_type") !== + "urn:ietf:params:oauth:grant-type:token-exchange" || + !form.get("subject_token") || + form.get("subject_token_type") !== ACCESS_TOKEN_URN || + (form.get("requested_token_type") ?? ACCESS_TOKEN_URN) !== + ACCESS_TOKEN_URN || + form.has("actor_token") || + form.has("audience") + ) + return error("invalid_request"); + const verified = await jwtVerify( + form.get("subject_token")!, + async () => publicKey, + { issuer: config.issuer, audience: config.audience }, + ).catch(() => null); + const parent = verified?.payload as Record | undefined; + if (!parent || parent.act || parent.parent_jti) + return error("invalid_grant"); + if (revoked.has(String(parent.jti))) return error("invalid_grant"); + const granted = String(parent.scope).split(" "); + const requested = (form.get("scope") ?? "").split(" ").filter(Boolean); + if (requested.some((scope) => !granted.includes(scope))) + return error("invalid_scope"); + const now = Math.floor(Date.now() / 1000); + const lifetime = Math.min(300, Number(parent.exp) - now); + if (lifetime <= 0) return error("invalid_grant"); + const scope = (requested.length ? requested : granted).join(" "); + const derived = await sign({ + ...parent, + aud: [config.apiAudience], + iat: now, + exp: now + lifetime, + jti: "jti_derived", + client_id: config.clientId, + scope, + act: { sub: `oauth_client:${config.clientId}` }, + parent_jti: parent.jti, + actor_registration_security_version: 1, + ...derivedOverrides, + }); + return Response.json({ + access_token: derived, + token_type: "Bearer", + expires_in: lifetime, + scope, + org_id: parent.org_id, + access_scope: parent.project_id ? "project" : "organization", + project_id: parent.project_id, + issued_token_type: ACCESS_TOKEN_URN, + ...responseOverrides, + }); + } + + return { + config, + sign, + parentClaims, + parent: (overrides: Record = {}) => + sign(parentClaims(overrides)), + request, + requests, + revoked, + deriveWith(overrides: Record) { + derivedOverrides = overrides; + }, + respondWith(overrides: Record) { + responseOverrides = overrides; + }, + }; +} diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 233d951b..d9b73f2d 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -1,165 +1,350 @@ -import { describe, expect, test } from "bun:test"; -import { generateKeyPair, exportJWK, SignJWT } from "jose"; +import { afterEach, describe, expect, test } from "bun:test"; +import { exportJWK, generateKeyPair } from "jose"; import { exchangeNativeOAuth, isNativeOAuthCredential, leaseNativeResponse, + NATIVE_STREAM_LEASE_MS, + nativeOAuthConfig, + NativeCredentialRejected, NativeScopeRejected, - type NativeOAuthConfig, } from "./native-oauth"; +import { nativeIssuer } from "./native-oauth.test-fixtures"; -async function fixture(clockOffsetSeconds = 0) { - const keys = await generateKeyPair("RS256"); - const jwk = { - ...(await exportJWK(keys.publicKey)), - alg: "RS256", - use: "sig", - kid: "test", - }; - const config: NativeOAuthConfig = { - issuer: "https://issuer.example", - audience: "https://mcp.example", - apiAudience: "https://api.example/", - epoch: 1, - clientId: "exchange-client", - clientSecret: "test-secret", - keys: { keys: [jwk] }, - }; - const now = Math.floor(Date.now() / 1000) + clockOffsetSeconds; - const claims = { - iss: config.issuer, - aud: [config.audience], - sub: "user_test", - jti: "parent", - iat: now, - exp: now + 300, - client_id: "client", - issuer_epoch: 1, - authorization_version: 1, - scope: "browsers:read", - }; - const token = await new SignJWT(claims) - .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "test" }) - .sign(keys.privateKey); - const derived = await new SignJWT({ - ...claims, - aud: [config.apiAudience], - jti: "derived", - exp: now + 45, - client_id: config.clientId, - act: { sub: `oauth-client:${config.clientId}` }, - }) - .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "test" }) - .sign(keys.privateKey); - const calls: string[] = []; - const request = async (url: string | URL | Request, init?: RequestInit) => { - calls.push(String(url)); - expect(init?.redirect).toBe("error"); - expect(init?.cache).toBe("no-store"); - if (String(url).endsWith("/introspect")) - return Response.json({ ...claims, active: true }); - const form = new URLSearchParams(String(init?.body)); - expect(form.get("subject_token")).toBe(token); - expect(form.get("resource")).toBe(config.apiAudience); - return Response.json({ - access_token: derived, - token_type: "Bearer", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - expires_in: 45, - scope: claims.scope, - }); - }; - return { config, token, derived, calls, request }; -} +const signal = () => new AbortController().signal; + +describe("native credential detection", () => { + test("selects only at+jwt tokens, as the Kernel API does", async () => { + const issuer = await nativeIssuer(); + expect(isNativeOAuthCredential(await issuer.parent())).toBe(true); + expect( + isNativeOAuthCredential( + await issuer.sign(issuer.parentClaims(), { + typ: "Application/AT+JWT", + }), + ), + ).toBe(true); + // Clerk session tokens, opaque API keys and malformed values keep their path. + expect( + isNativeOAuthCredential( + await issuer.sign(issuer.parentClaims(), { typ: "JWT" }), + ), + ).toBe(false); + expect(isNativeOAuthCredential("sk_opaque_key")).toBe(false); + expect(isNativeOAuthCredential("krn_rt1_value")).toBe(false); + expect(isNativeOAuthCredential("a.b.c")).toBe(false); + }); +}); -describe("native OAuth MCP consumer", () => { - test("exchanges rather than passing through, rechecking status on every request", async () => { - const f = await fixture(); +describe("native token exchange", () => { + test("exchanges every request with the issuer's RFC 8693 contract", async () => { + const issuer = await nativeIssuer(); + const parent = await issuer.parent(); for (let n = 0; n < 2; n++) { const result = await exchangeNativeOAuth( - f.token, - new AbortController().signal, - f.config, - f.request, + parent, + signal(), + issuer.config, + issuer.request, ); - expect(result.token).toBe(f.derived); - expect(result.token).not.toBe(f.token); - expect(result.scopes).toEqual(["browsers:read"]); + expect(result.token).not.toBe(parent); + expect(result.subject).toBe("user_123"); + expect(result.scopes).toEqual(["openid", "email"]); } - expect(f.calls.length).toBe(4); + expect(issuer.requests).toHaveLength(2); + const { url, init } = issuer.requests[0]!; + expect(url).toBe("https://auth.example.test/token"); + expect(init.redirect).toBe("error"); + expect(init.cache).toBe("no-store"); + expect(Object.fromEntries(new URLSearchParams(String(init.body)))).toEqual({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: parent, + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + scope: "openid email", + }); }); - test("bounds the lease by relative lifetime despite issuer clock skew", async () => { - const f = await fixture(20); + + test("accepts five-minute exchanged tokens but leases one minute", async () => { + const issuer = await nativeIssuer(); const started = Date.now(); const result = await exchangeNativeOAuth( - f.token, - new AbortController().signal, - f.config, - f.request, + await issuer.parent(), + signal(), + issuer.config, + issuer.request, ); - expect(result.deadline).toBeGreaterThan(started + 44000); - expect(result.deadline).toBeLessThanOrEqual(Date.now() + 45000); - }); - test("rejects wrong audiences and epochs before network access", async () => { - const f = await fixture(); - for (const config of [ - { ...f.config, audience: f.config.apiAudience }, - { ...f.config, epoch: 2 }, - ]) { - await expect( - exchangeNativeOAuth( - f.token, - new AbortController().signal, - config, - f.request, - ), - ).rejects.toThrow(); - } - expect(f.calls).toEqual([]); - }); - test("revocation and authority outages cannot become Clerk fallback", async () => { - const f = await fixture(); - for (const response of [ - Response.json({ active: false }), - new Response(null, { status: 503 }), - ]) { - let calls = 0; - const request = async () => { - calls++; - return response; - }; + expect(result.deadline).toBeGreaterThanOrEqual( + started + NATIVE_STREAM_LEASE_MS, + ); + expect(result.deadline).toBeLessThanOrEqual( + Date.now() + NATIVE_STREAM_LEASE_MS, + ); + }); + + test("bounds the lease by a shorter remaining parent lifetime despite issuer skew", async () => { + const issuer = await nativeIssuer(); + const skew = 20; + const now = Math.floor(Date.now() / 1000) + skew; + // The issuer derives the remaining lifetime from its own clock; only the + // relative lifetime bounds the local lease. + issuer.deriveWith({ iat: now, exp: now + 15 }); + issuer.respondWith({ expires_in: 15 }); + const started = Date.now(); + const result = await exchangeNativeOAuth( + await issuer.parent({ exp: now + 15 }), + signal(), + issuer.config, + issuer.request, + ); + expect(result.deadline).toBeGreaterThanOrEqual(started + 15000); + expect(result.deadline).toBeLessThanOrEqual(Date.now() + 15000); + }); + + test("a parent at the end of its life is rejected for refresh", async () => { + const issuer = await nativeIssuer(); + issuer.respondWith({ expires_in: 0 }); + await expect( + exchangeNativeOAuth( + await issuer.parent(), + signal(), + issuer.config, + issuer.request, + ), + ).rejects.toBeInstanceOf(NativeCredentialRejected); + }); + + test("rejects parents outside the issuer profile before any network call", async () => { + const issuer = await nativeIssuer(); + const now = Math.floor(Date.now() / 1000); + const other = await generateKeyPair("RS256"); + const rejected = [ + await issuer.parent({ aud: [issuer.config.apiAudience] }), + await issuer.parent({ aud: [issuer.config.audience, "https://x.test"] }), + await issuer.parent({ iss: "https://other.example.test" }), + await issuer.parent({ issuer_epoch: 2 }), + await issuer.parent({ authorization_version: 2 }), + await issuer.parent({ iat: now - 700, exp: now + 1 }), + await issuer.parent({ iat: now - 120, exp: now - 60 }), + await issuer.parent({ grant_id: undefined }), + await issuer.parent({ + act: { sub: "oauth_client:kcl_mcp" }, + parent_jti: "jti_root", + }), + await issuer.sign(issuer.parentClaims(), { typ: "JWT" }), + await issuer.sign(issuer.parentClaims(), { kid: "unknown" }), + await issuer.sign(issuer.parentClaims(), { kid: undefined }), + await issuer.parent({ pad: "x".repeat(4096) }), + ]; + for (const token of rejected) await expect( - exchangeNativeOAuth( - f.token, - new AbortController().signal, - f.config, - request, - ), - ).rejects.toThrow(); - expect(calls).toBe(1); + exchangeNativeOAuth(token, signal(), issuer.config, issuer.request), + ).rejects.toBeInstanceOf(NativeCredentialRejected); + await expect( + exchangeNativeOAuth( + await issuer.parent(), + signal(), + { + ...issuer.config, + keys: { + keys: [{ ...(await exportJWK(other.publicKey)), kid: "kid-test" }], + }, + }, + issuer.request, + ), + ).rejects.toBeInstanceOf(NativeCredentialRejected); + expect(issuer.requests).toEqual([]); + }); + + test("revocation is a credential rejection, never a fallback", async () => { + const issuer = await nativeIssuer(); + issuer.revoked.add("jti_parent"); + await expect( + exchangeNativeOAuth( + await issuer.parent(), + signal(), + issuer.config, + issuer.request, + ), + ).rejects.toBeInstanceOf(NativeCredentialRejected); + expect(issuer.requests).toHaveLength(1); + }); + + test("scope rejection is permanent; other issuer failures are retryable", async () => { + const issuer = await nativeIssuer(); + const parent = await issuer.parent(); + const cases: [Response, unknown][] = [ + [ + Response.json({ error: "invalid_scope" }, { status: 400 }), + NativeScopeRejected, + ], + [ + Response.json({ error: "invalid_grant" }, { status: 400 }), + NativeCredentialRejected, + ], + [Response.json({ error: "unauthorized_client" }, { status: 400 }), Error], + [Response.json({ error: "invalid_client" }, { status: 401 }), Error], + [ + Response.json({ error: "temporarily_unavailable" }, { status: 503 }), + Error, + ], + [new Response("", { status: 400 }), Error], + ]; + for (const [response, kind] of cases) { + const failure = await exchangeNativeOAuth( + parent, + signal(), + issuer.config, + async () => response, + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(kind as typeof Error); + if (kind === Error) { + expect(failure).not.toBeInstanceOf(NativeCredentialRejected); + expect(failure).not.toBeInstanceOf(NativeScopeRejected); + } } - expect(isNativeOAuthCredential(f.token)).toBe(true); - expect(isNativeOAuthCredential("krn_rt1_invalid")).toBe(true); - expect(isNativeOAuthCredential("kernel_api_key")).toBe(false); - }); - test("treats issuer scope and target rejection as permanent scope failures", async () => { - for (const error of ["invalid_scope", "invalid_target"]) { - const f = await fixture(); - const request = async (url: string, init: RequestInit) => - url.endsWith("/token") - ? Response.json({ error }, { status: 400 }) - : f.request(url, init); - await expect( - exchangeNativeOAuth( - f.token, - new AbortController().signal, - f.config, - request, - ), - ).rejects.toBeInstanceOf(NativeScopeRejected); + }); + + test("a derived token outside the exchange lineage fails closed", async () => { + const issuer = await nativeIssuer(); + const parent = await issuer.parent(); + const now = Math.floor(Date.now() / 1000); + const broken: [Record, Record][] = [ + [{ aud: [issuer.config.audience] }, {}], + [{ iat: now, exp: now + 301 }, {}], + [{ exp: now + 601, iat: now + 400 }, {}], + [{ sub: "user_other" }, {}], + [{ grant_id: "grant_other" }, {}], + [{ org_id: "org_other" }, {}], + [{ project_id: undefined }, {}], + [{ client_id: "kcl_other" }, {}], + [{ act: { sub: "oauth_client:kcl_other" } }, {}], + [{ act: undefined }, {}], + [{ parent_jti: "jti_other" }, {}], + [{ issuer_epoch: 2 }, {}], + [{ scope: "openid email admin" }, { scope: "openid email admin" }], + [{}, { scope: "openid" }], + [{}, { issued_token_type: "urn:ietf:params:oauth:token-type:jwt" }], + ]; + for (const [derived, response] of broken) { + issuer.deriveWith(derived); + issuer.respondWith(response); + const failure = await exchangeNativeOAuth( + parent, + signal(), + issuer.config, + issuer.request, + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(NativeCredentialRejected); } }); - test("closes a stream when its short authorization lease ends", async () => { + + test("the exchange request ends with the client request", async () => { + const issuer = await nativeIssuer(); + const abort = new AbortController(); + abort.abort(); + let seen: AbortSignal | undefined; + await expect( + exchangeNativeOAuth( + await issuer.parent(), + abort.signal, + issuer.config, + async (_url, init) => { + seen = init.signal ?? undefined; + throw new Error("aborted"); + }, + ), + ).rejects.toThrow("aborted"); + expect(seen?.aborted).toBe(true); + }); +}); + +describe("native configuration", () => { + const saved = { ...process.env }; + afterEach(() => { + for (const key of Object.keys(process.env)) + if (key.startsWith("OAUTH_NATIVE_")) delete process.env[key]; + Object.assign(process.env, saved); + }); + + async function enable(overrides: Record = {}) { + const { publicKey } = await generateKeyPair("RS256"); + Object.assign(process.env, { + OAUTH_NATIVE_ENABLED: "true", + OAUTH_NATIVE_ISSUER: "https://auth.dev.example.test", + OAUTH_NATIVE_MCP_AUDIENCE: "https://mcp.dev.example.test", + OAUTH_NATIVE_API_AUDIENCE: "https://api.dev.example.test", + OAUTH_NATIVE_ISSUER_EPOCH: "1", + OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify({ + keys: [ + { + ...(await exportJWK(publicKey)), + kid: "k1", + alg: "RS256", + use: "sig", + }, + ], + }), + OAUTH_NATIVE_EXCHANGE_CLIENT: "kcl_mcp", + OAUTH_NATIVE_EXCHANGE_SECRET: "secret", + }); + for (const [key, value] of Object.entries(overrides)) + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + test("is off without any native settings", () => { + delete process.env.OAUTH_NATIVE_ENABLED; + expect(nativeOAuthConfig()).toBeNull(); + process.env.OAUTH_NATIVE_ENABLED = "false"; + expect(nativeOAuthConfig()).toBeNull(); + }); + + test("pins every trust input when enabled", async () => { + await enable(); + const config = nativeOAuthConfig()!; + expect(config.issuer).toBe("https://auth.dev.example.test"); + expect(config.audience).toBe("https://mcp.dev.example.test"); + expect(config.apiAudience).toBe("https://api.dev.example.test"); + expect(Object.keys(config.keys.keys[0]!).sort()).toEqual([ + "e", + "kid", + "kty", + "n", + ]); + }); + + test.each([ + ["OAUTH_NATIVE_ISSUER", undefined], + ["OAUTH_NATIVE_ISSUER", "http://auth.dev.example.test"], + ["OAUTH_NATIVE_MCP_AUDIENCE", "https://mcp.dev.example.test/mcp"], + ["OAUTH_NATIVE_API_AUDIENCE", "https://api.dev.example.test/"], + ["OAUTH_NATIVE_API_AUDIENCE", "https://mcp.dev.example.test"], + ["OAUTH_NATIVE_ISSUER_EPOCH", "0"], + ["OAUTH_NATIVE_VERIFICATION_JWKS", undefined], + ["OAUTH_NATIVE_VERIFICATION_JWKS", '{"keys":[]}'], + ["OAUTH_NATIVE_EXCHANGE_SECRET", undefined], + ])("rejects %s=%s", async (key, value) => { + await enable({ [key]: value }); + expect(() => nativeOAuthConfig()).toThrow(); + }); + + test("refuses private key material in the verification set", async () => { + const { privateKey } = await generateKeyPair("RS256", { + extractable: true, + }); + await enable({ + OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify({ + keys: [{ ...(await exportJWK(privateKey)), kid: "k1" }], + }), + }); + expect(() => nativeOAuthConfig()).toThrow(); + }); +}); + +describe("native stream lease", () => { + test("closes a stream when its authorization lease ends", async () => { let cancelled = false; const abort = new AbortController(); const response = leaseNativeResponse( diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 00c6e4d4..ecedd986 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -1,43 +1,73 @@ import { - decodeProtectedHeader, - decodeJwt, createLocalJWKSet, + decodeProtectedHeader, jwtVerify, type JSONWebKeySet, } from "jose"; import { z } from "zod"; -const claimsSchema = z.object({ - active: z.literal(true), +// Mirrors the Kernel API issuer profile (packages/api/lib/oauthnative): +// MaxTokenBytes, AccessTokenLifetime, ExchangeTokenLifetime and the +// signature leeway. +const MAX_TOKEN_BYTES = 4096; +const ACCESS_TOKEN_LIFETIME_SECONDS = 600; +const EXCHANGE_TOKEN_LIFETIME_SECONDS = 300; +const CLOCK_TOLERANCE_SECONDS = 30; +const ACCESS_TOKEN_TYPES = ["at+jwt", "application/at+jwt"]; +const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; + +// A native request or stream is authorized for at most one minute, however +// long the exchanged token lives; continuing requires a fresh exchange. +export const NATIVE_STREAM_LEASE_MS = 60_000; + +const accessClaimsSchema = z.object({ iss: z.string(), - aud: z.array(z.string()).length(1), sub: z.string().min(1), + aud: z.array(z.string()).length(1), jti: z.string().min(1), + iat: z.number().int(), + exp: z.number().int(), client_id: z.string().min(1), - issuer_epoch: z.number().int().positive(), + grant_id: z.string().min(1), + org_id: z.string().min(1), + project_id: z.string().optional(), scope: z.string().min(1), - exp: z.number(), + authorization_version: z.literal(1), + issuer_epoch: z.number().int(), + act: z.object({ sub: z.string() }).optional(), + parent_jti: z.string().optional(), }); +type AccessClaims = z.infer; + const exchangeSchema = z.object({ - access_token: z.string().min(1).max(4096), + access_token: z.string().min(1).max(MAX_TOKEN_BYTES), token_type: z.literal("Bearer"), - issued_token_type: z.literal("urn:ietf:params:oauth:token-type:access_token"), - expires_in: z.number().positive().max(60), - scope: z.string(), + issued_token_type: z.literal(ACCESS_TOKEN_URN), + expires_in: z.number().int().nonnegative(), + scope: z.string().min(1), +}); + +const jwksSchema = z.object({ + keys: z + .array( + z.object({ + kty: z.literal("RSA"), + kid: z.string().min(1), + n: z.string().min(1), + e: z.string().min(1), + d: z.undefined(), + }), + ) + .min(1), }); +// Matches the API's own selector: a compact JWT whose header typ is at+jwt. +// Clerk session tokens carry typ JWT and keep their existing path. export function isNativeOAuthCredential(token: string): boolean { - if (token.startsWith("krn_")) return true; - if (token.length > 16384) return false; + if (token.split(".").length !== 3) return false; try { - const header = decodeProtectedHeader(token); - const claims = decodeJwt(token); - return ( - header.typ === "at+jwt" || - header.typ === "application/at+jwt" || - "issuer_epoch" in claims || - "authorization_version" in claims - ); + const typ = decodeProtectedHeader(token).typ?.toLowerCase(); + return typ !== undefined && ACCESS_TOKEN_TYPES.includes(typ); } catch { return false; } @@ -51,7 +81,7 @@ export class NativeCredentialRejected extends Error { export class NativeScopeRejected extends Error { constructor() { - super("native scope or resource rejected"); + super("native scope rejected"); } } @@ -65,30 +95,72 @@ export type NativeOAuthConfig = { keys: JSONWebKeySet; }; +function httpsOrigin(name: string): string { + const value = process.env[name]; + if (!value || new URL(value).protocol !== "https:") + throw new Error(`${name} must be an HTTPS origin`); + if (new URL(value).origin !== value) + throw new Error(`${name} must be an origin without a trailing slash`); + return value; +} + +// Returns null unless explicitly enabled. Every trust input is required and +// has no default, so an enabled deployment cannot silently trust another +// environment's issuer. export function nativeOAuthConfig(): NativeOAuthConfig | null { if (process.env.OAUTH_NATIVE_ENABLED !== "true") return null; - const issuer = process.env.OAUTH_NATIVE_ISSUER ?? "https://auth.onkernel.com"; - const parsed = new URL(issuer); - if (parsed.protocol !== "https:" || parsed.origin !== issuer) - throw new Error("invalid native OAuth issuer"); + const issuer = httpsOrigin("OAUTH_NATIVE_ISSUER"); + const audience = httpsOrigin("OAUTH_NATIVE_MCP_AUDIENCE"); + const apiAudience = httpsOrigin("OAUTH_NATIVE_API_AUDIENCE"); + if (audience === apiAudience) + throw new Error("native OAuth MCP and API audiences must differ"); + const epoch = Number(process.env.OAUTH_NATIVE_ISSUER_EPOCH); const clientId = process.env.OAUTH_NATIVE_EXCHANGE_CLIENT; const clientSecret = process.env.OAUTH_NATIVE_EXCHANGE_SECRET; - const epoch = Number(process.env.OAUTH_NATIVE_ISSUER_EPOCH); if (!clientId || !clientSecret || !Number.isSafeInteger(epoch) || epoch < 1) throw new Error("incomplete native OAuth configuration"); return { issuer, - audience: - process.env.OAUTH_NATIVE_MCP_AUDIENCE ?? "https://mcp.onkernel.com", - apiAudience: - process.env.OAUTH_NATIVE_API_AUDIENCE ?? "https://api.onkernel.com/", + audience, + apiAudience, epoch, clientId, clientSecret, - keys: JSON.parse(process.env.OAUTH_NATIVE_VERIFICATION_JWKS ?? "{}"), + keys: jwksSchema.parse( + JSON.parse(process.env.OAUTH_NATIVE_VERIFICATION_JWKS ?? ""), + ), }; } +async function verifyAccessToken( + token: string, + audience: string, + maxLifetimeSeconds: number, + config: NativeOAuthConfig, +): Promise { + const { payload, protectedHeader } = await jwtVerify( + token, + createLocalJWKSet(config.keys), + { + issuer: config.issuer, + audience, + algorithms: ["RS256"], + clockTolerance: CLOCK_TOLERANCE_SECONDS, + requiredClaims: ["iat", "exp"], + }, + ); + const claims = accessClaimsSchema.parse(payload); + if ( + !protectedHeader.kid || + !ACCESS_TOKEN_TYPES.includes(protectedHeader.typ?.toLowerCase() ?? "") || + claims.issuer_epoch !== config.epoch || + claims.exp - claims.iat <= 0 || + claims.exp - claims.iat > maxLifetimeSeconds + ) + throw new Error("invalid native access token"); + return claims; +} + // All HTTP destinations and verification keys come from server configuration, never JWT claims. export async function exchangeNativeOAuth( token: string, @@ -96,159 +168,88 @@ export async function exchangeNativeOAuth( config: NativeOAuthConfig, request: (url: string, init: RequestInit) => Promise = fetch, ) { - if (token.length > 4096) throw new NativeCredentialRejected(); - const verified = await jwtVerify(token, createLocalJWKSet(config.keys), { - issuer: config.issuer, - audience: config.audience, - algorithms: ["RS256"], - clockTolerance: 30, - requiredClaims: [ - "sub", - "jti", - "iat", - "exp", - "client_id", - "issuer_epoch", - "scope", - ], - }).catch(() => { + if (token.length > MAX_TOKEN_BYTES) throw new NativeCredentialRejected(); + const parent = await verifyAccessToken( + token, + config.audience, + ACCESS_TOKEN_LIFETIME_SECONDS, + config, + ).catch(() => { throw new NativeCredentialRejected(); }); - if ( - !["at+jwt", "application/at+jwt"].includes( - verified.protectedHeader.typ ?? "", - ) || - verified.payload.issuer_epoch !== config.epoch || - !Array.isArray(verified.payload.aud) || - verified.payload.aud.length !== 1 || - typeof verified.payload.iat !== "number" || - typeof verified.payload.exp !== "number" || - verified.payload.exp - verified.payload.iat > 600 || - verified.payload.act - ) - throw new NativeCredentialRejected(); - const authorization = `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`; - const post = async (path: string, form: URLSearchParams) => { - const response = await request(`${config.issuer}${path}`, { - method: "POST", - headers: { - Authorization: authorization, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: form, - cache: "no-store", - redirect: "error", - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - if (response.status >= 500) - throw new Error("native OAuth authority unavailable"); - const text = await response.text(); - if (text.length > 16384) throw new Error("invalid native OAuth response"); - const body: unknown = JSON.parse(text); - if (!response.ok) { - const failure = z.object({ error: z.string() }).safeParse(body); - if ( - response.status === 400 && - failure.success && - failure.data.error === "invalid_grant" - ) - throw new NativeCredentialRejected(); - if ( - response.status === 400 && - failure.success && - ["invalid_scope", "invalid_target"].includes(failure.data.error) - ) - throw new NativeScopeRejected(); - throw new Error("native OAuth authority request failed"); - } - return body; - }; - const introspection = await post( - "/oauth/native/introspect", - new URLSearchParams({ token }), - ); - if (z.object({ active: z.literal(false) }).safeParse(introspection).success) - throw new NativeCredentialRejected(); - const status = claimsSchema.parse(introspection); - if ( - status.iss !== config.issuer || - status.aud[0] !== config.audience || - status.issuer_epoch !== config.epoch || - status.sub !== verified.payload.sub || - status.jti !== verified.payload.jti || - status.client_id !== verified.payload.client_id || - status.scope !== verified.payload.scope || - status.exp !== verified.payload.exp || - status.exp * 1000 + 30000 <= Date.now() - ) - throw new NativeCredentialRejected(); - const exchangeStartedAt = Date.now(); - const exchanged = exchangeSchema.parse( - await post( - "/token", - new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", - subject_token: token, - subject_token_type: "urn:ietf:params:oauth:token-type:access_token", - requested_token_type: "urn:ietf:params:oauth:token-type:access_token", - resource: config.apiAudience, - scope: status.scope, - }), - ), - ); - const derived = await jwtVerify( - exchanged.access_token, - createLocalJWKSet(config.keys), - { - issuer: config.issuer, - audience: config.apiAudience, - algorithms: ["RS256"], - clockTolerance: 30, - requiredClaims: [ - "iat", - "exp", - "sub", - "client_id", - "scope", - "issuer_epoch", - ], + // Exchanged tokens cannot be exchanged again, and only the issuer's + // authoritative status check (performed by the exchange) admits a parent. + if (parent.act || parent.parent_jti) throw new NativeCredentialRejected(); + + const startedAt = Date.now(); + const response = await request(`${config.issuer}/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: token, + subject_token_type: ACCESS_TOKEN_URN, + requested_token_type: ACCESS_TOKEN_URN, + scope: parent.scope, + }), + cache: "no-store", + redirect: "error", + signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), + }); + if (response.status >= 500) + throw new Error("native OAuth authority unavailable"); + const text = await response.text(); + if (text.length > 16384) throw new Error("invalid native OAuth response"); + const body: unknown = JSON.parse(text); + if (!response.ok) { + const failure = z.object({ error: z.string() }).safeParse(body); + if (response.status === 400 && failure.data?.error === "invalid_grant") + throw new NativeCredentialRejected(); + if (response.status === 400 && failure.data?.error === "invalid_scope") + throw new NativeScopeRejected(); + throw new Error("native OAuth exchange failed"); + } + + const exchanged = exchangeSchema.parse(body); + const derived = await verifyAccessToken( + exchanged.access_token, + config.apiAudience, + EXCHANGE_TOKEN_LIFETIME_SECONDS, + config, ); + const parentScopes = parent.scope.split(" "); if ( - derived.payload.sub !== status.sub || - derived.payload.client_id !== config.clientId || - derived.payload.issuer_epoch !== config.epoch || - derived.payload.scope !== exchanged.scope || - !["at+jwt", "application/at+jwt"].includes( - derived.protectedHeader.typ ?? "", - ) || - !Array.isArray(derived.payload.aud) || - derived.payload.aud.length !== 1 || - typeof derived.payload.exp !== "number" || - derived.payload.exp > status.exp || - typeof derived.payload.iat !== "number" || - derived.payload.exp - derived.payload.iat > 60 || - derived.payload.exp <= derived.payload.iat || - exchanged.scope - .split(" ") - .some((scope) => !status.scope.split(" ").includes(scope)) + derived.sub !== parent.sub || + derived.grant_id !== parent.grant_id || + derived.org_id !== parent.org_id || + (derived.project_id ?? "") !== (parent.project_id ?? "") || + derived.client_id !== config.clientId || + derived.act?.sub !== `oauth_client:${config.clientId}` || + derived.parent_jti !== parent.jti || + derived.exp > parent.exp || + derived.scope !== exchanged.scope || + exchanged.scope.split(" ").some((scope) => !parentScopes.includes(scope)) ) throw new Error("invalid native exchange"); + + // expires_in and exp - iat are relative, so issuer clock skew cannot + // extend the lease past the token's real remaining lifetime. + const lifetimeMs = + Math.min(exchanged.expires_in, derived.exp - derived.iat) * 1000; + if (lifetimeMs <= 0) throw new NativeCredentialRejected(); return { token: exchanged.access_token, - subject: status.sub, + subject: parent.sub, scopes: exchanged.scope.split(" "), - deadline: - exchangeStartedAt + - Math.min( - exchanged.expires_in, - derived.payload.exp - derived.payload.iat, - ) * - 1000, + deadline: startedAt + Math.min(lifetimeMs, NATIVE_STREAM_LEASE_MS), }; } -// Native streams have a one-minute maximum lease; reconnect requires fresh authorization. +// Ends a native response when its authorization lease expires; reconnecting +// requires a fresh exchange. export function leaseNativeResponse( response: Response, deadline: number, diff --git a/src/lib/oauth-compatibility.ts b/src/lib/oauth-compatibility.ts deleted file mode 100644 index f6237f31..00000000 --- a/src/lib/oauth-compatibility.ts +++ /dev/null @@ -1,18 +0,0 @@ -type CompatibilityEvent = { - surface: "verification" | "token" | "authorize" | "register"; - provider: "clerk" | "kernel"; - outcome: "verified" | "rejected" | "success" | "error" | "unavailable"; -}; - -// Categorical events deliberately exclude identities, tokens, URLs and request bodies. -export function recordOAuthCompatibility(event: CompatibilityEvent): void { - console.info( - JSON.stringify({ - event: "oauth_compatibility", - version: 1, - issuer_alias: event.provider === "kernel" ? "canonical" : "legacy_mcp", - cohort: event.provider === "kernel" ? "test" : "unknown", - ...event, - }), - ); -} From 780aaad33897bc72ef4459ab0dfbe226917154d9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:47:39 +0000 Subject: [PATCH 06/12] Reproduce native identity churn with rotating derived tokens --- src/app/[transport]/route.test.ts | 116 +++++++++++++++++++++++++- src/lib/native-oauth.test-fixtures.ts | 2 +- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 3183bd2b..f3f0e2e3 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -13,6 +13,8 @@ import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; import { clearMcpEntitlementsCacheForTests } from "@/lib/mcp/entitlements"; +import { MCP_SESSION_HEADER } from "@posthog/mcp"; +import { mcpAppsMarkerKey } from "@/lib/mcp-apps-marker"; import { oauthResourceMetadata } from "@/lib/oauth-discovery"; process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; @@ -29,8 +31,14 @@ const captured: McpConnectionScopeFailureAnalytics[] = []; const analytics = await import("@/lib/mcp/analytics"); const captureMcpConnectionScopeFailure = analytics.captureMcpConnectionScopeFailure; +const instrumentMcpAnalytics = analytics.instrumentMcpAnalytics; +let analyticsClient: unknown = null; mock.module("@/lib/mcp/analytics", () => ({ ...analytics, + instrumentMcpAnalytics: ( + ...[server, client]: Parameters + ) => + instrumentMcpAnalytics(server, (analyticsClient as typeof client) ?? client), captureMcpConnectionScopeFailure: ( ...args: Parameters ) => { @@ -39,6 +47,30 @@ mock.module("@/lib/mcp/analytics", () => ({ }, })); +// An in-memory MCP Apps marker store, keyed exactly as Redis keys it. +let appMarkers: Set | null = null; +const redis = await import("@/lib/redis"); +const { markMcpAppsClient, clearMcpAppsClient, hasMcpAppsClient } = redis; +const markerKey = (marker: { + authSubject: string; + transportSessionId: string; +}) => mcpAppsMarkerKey(marker.authSubject, marker.transportSessionId); +mock.module("@/lib/redis", () => ({ + ...redis, + markMcpAppsClient: async (marker: Parameters[0]) => + appMarkers + ? void appMarkers.add(markerKey(marker)) + : markMcpAppsClient(marker), + clearMcpAppsClient: async ( + marker: Parameters[0], + ) => + appMarkers + ? void appMarkers.delete(markerKey(marker)) + : clearMcpAppsClient(marker), + hasMcpAppsClient: async (marker: Parameters[0]) => + appMarkers ? appMarkers.has(markerKey(marker)) : hasMcpAppsClient(marker), +})); + const originalCreateKernelClient = defaultMcpDependencies.createKernelClient; const { GET, POST, connectionScopeFailureResponse } = await import("./route"); @@ -91,11 +123,13 @@ describe("native OAuth credentials", () => { ); let issuer: Awaited>; let kernelTokens: string[]; + let kernelPaths: string[]; let fetchSpy: ReturnType>; beforeEach(async () => { issuer = await nativeIssuer(); kernelTokens = []; + kernelPaths = []; defaultMcpDependencies.createKernelClient = (token) => { kernelTokens.push(token); return new Kernel({ @@ -104,6 +138,9 @@ describe("native OAuth credentials", () => { maxRetries: 0, fetch: async (input) => { const path = new URL(String(input)).pathname; + kernelPaths.push(path); + if (path === "/org/entitlements") + return Response.json({ features: { vaults: { enabled: true } } }); if (path !== "/auth/context") throw new Error(`Unexpected API request: ${path}`); // GET /auth/context as the API answers an exchanged user token. @@ -130,6 +167,8 @@ describe("native OAuth credentials", () => { }); afterEach(() => { + appMarkers = null; + analyticsClient = null; fetchSpy.mockRestore(); for (const key of Object.keys(process.env)) if (key.startsWith("OAUTH_NATIVE_") && !nativeEnv.includes(key)) @@ -149,18 +188,91 @@ describe("native OAuth credentials", () => { }); } - function mcpRequest(token: string, method = "tools/list") { + function mcpRequest( + token: string, + method = "tools/list", + params?: object, + headers: Record = {}, + ) { return new nextServer.NextRequest("https://mcp.example.test/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", + ...headers, }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method }), + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); } + // A refreshed MCP token carries a new jti, so every derived token differs. + const refreshed = () => issuer.parent({ jti: crypto.randomUUID() }); + + test("keep MCP Apps capability from initialize across rotated tokens", async () => { + enable(); + appMarkers = new Set(); + const initialized = await POST( + mcpRequest(await refreshed(), "initialize", { + protocolVersion: "2025-06-18", + capabilities: { extensions: { "io.modelcontextprotocol/ui": {} } }, + clientInfo: { name: "apps-host", version: "1" }, + }), + ); + expect(initialized.status).toBe(200); + const session = initialized.headers.get(MCP_SESSION_HEADER)!; + expect(session).toBeTruthy(); + await initialized.text(); + + const call = await POST( + mcpRequest( + await refreshed(), + "tools/call", + { + name: "begin_auth_login", + arguments: { mode: "reauth", connection_id: "conn_1" }, + }, + { [MCP_SESSION_HEADER]: session }, + ), + ); + expect(call.status).toBe(200); + const text = await call.text(); + expect(text).toContain('"result"'); + expect(text).not.toContain("only available to the secure KERNEL login app"); + }); + + test("reuse the entitlements snapshot across rotated tokens", async () => { + enable(); + for (let n = 0; n < 2; n++) { + const response = await POST(mcpRequest(await refreshed())); + expect(response.status).toBe(200); + expect(await response.text()).toContain("manage_vaults"); + } + expect(new Set(kernelTokens).size).toBe(2); + expect(kernelPaths.filter((path) => path === "/org/entitlements")).toEqual( + ["/org/entitlements"], + ); + }); + + test("keep one analytics session across rotated tokens", async () => { + enable(); + const events: { properties: Record }[] = []; + analyticsClient = { + capture: (event: { properties: Record }) => + events.push(event), + }; + for (let n = 0; n < 2; n++) { + const response = await POST(mcpRequest(await refreshed())); + expect(response.status).toBe(200); + await response.text(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events.length).toBeGreaterThanOrEqual(2); + expect( + new Set(events.map((event) => event.properties.$session_id)).size, + ).toBe(1); + }); + test("are rejected without contacting the issuer or API while disabled", async () => { delete process.env.OAUTH_NATIVE_ENABLED; const response = await POST(mcpRequest(await issuer.parent())); diff --git a/src/lib/native-oauth.test-fixtures.ts b/src/lib/native-oauth.test-fixtures.ts index d3f9cc0f..362243af 100644 --- a/src/lib/native-oauth.test-fixtures.ts +++ b/src/lib/native-oauth.test-fixtures.ts @@ -100,7 +100,7 @@ export async function nativeIssuer() { aud: [config.apiAudience], iat: now, exp: now + lifetime, - jti: "jti_derived", + jti: crypto.randomUUID(), client_id: config.clientId, scope, act: { sub: `oauth_client:${config.clientId}` }, From d698c95192c188783664622f7e2aed0395f9e354 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:56:27 +0000 Subject: [PATCH 07/12] Verify native tokens against the issuer JWKS and cache exchanged tokens 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=, 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. --- .env.example | 18 +- docs/native-oauth.md | 221 +++++----- src/app/[transport]/route.test.ts | 233 ++++++++--- src/app/[transport]/route.ts | 85 ++-- src/lib/native-oauth.test-fixtures.ts | 58 ++- src/lib/native-oauth.test.ts | 567 ++++++++++++++------------ src/lib/native-oauth.ts | 561 +++++++++++++++---------- src/lib/oauth-discovery-route.test.ts | 2 + src/lib/oauth-discovery.test.ts | 2 + src/lib/oauth-discovery.ts | 34 +- 10 files changed, 1058 insertions(+), 723 deletions(-) diff --git a/.env.example b/.env.example index 4d9b10bd..bdb7dd18 100644 --- a/.env.example +++ b/.env.example @@ -7,16 +7,14 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ -# Kernel-issued (native) OAuth consumer. Off unless OAUTH_NATIVE_ENABLED=true; -# when enabled every value below is required. See docs/native-oauth.md. -# OAUTH_NATIVE_ENABLED=false -# OAUTH_NATIVE_ISSUER=https://auth.dev.onkernel.com -# OAUTH_NATIVE_MCP_AUDIENCE=https://mcp.dev.onkernel.com -# OAUTH_NATIVE_API_AUDIENCE=https://api.dev.onkernel.com -# OAUTH_NATIVE_ISSUER_EPOCH=1 -# OAUTH_NATIVE_VERIFICATION_JWKS={"keys":[...]} -# OAUTH_NATIVE_EXCHANGE_CLIENT=kcl_ -# OAUTH_NATIVE_EXCHANGE_SECRET=kcs_ +# Kernel-issued (native) OAuth token exchange client. Required for native +# OAuth tokens; without it they fail with a server error while Clerk tokens and +# API keys keep working. See docs/native-oauth.md. +NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID= +NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET= +# Only for origins other than mcp.onkernel.com / mcp.dev.onkernel.com, such as +# local development against a local API: http://localhost:3001 +# NATIVE_OAUTH_ISSUER= # OAuth Configuration NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev diff --git a/docs/native-oauth.md b/docs/native-oauth.md index fbdcdf7d..9bd073b5 100644 --- a/docs/native-oauth.md +++ b/docs/native-oauth.md @@ -1,112 +1,131 @@ -# Kernel-issued (native) OAuth consumer +# Kernel-issued (native) OAuth -The MCP server can accept access tokens issued by the Kernel API's native -issuer (`packages/api/lib/oauthnative` in kernel/kernel). It is off by default. -With `OAUTH_NATIVE_ENABLED` unset or not `true`, nothing else is read and no -new secret is required. +The MCP server accepts access tokens issued by Kernel's own authorization +server alongside Clerk OAuth tokens and API keys. Clients discover it through +protected-resource metadata and authorize with `resource` set to the MCP +endpoint. ## Behavior A bearer token is native when it is a compact JWT whose header `typ` is -`at+jwt` or `application/at+jwt`, the same selector the Kernel API uses. -Clerk session tokens (`typ: JWT`) and opaque API keys keep their existing -paths. A native token never falls back to Clerk or to API-key handling. - -| State | Native token | -| -------------------------------- | ----------------------------------------------------------- | -| disabled | 401 `invalid_token`, no issuer or API call | -| enabled, configuration invalid | 503 `temporarily_unavailable` | -| signature/claims rejected | 401 before any network call | -| issuer answers `invalid_grant` | 401 (revoked, expired, inactive grant) | -| issuer answers `invalid_scope` | 403 `insufficient_scope` | -| any other issuer failure | 503 `temporarily_unavailable` | -| exchanged token fails validation | 503 `temporarily_unavailable` | -| exchange succeeds | request proceeds with the exchanged token, one-minute lease | - -For every request the server: - -1. Verifies the token locally against the pinned JWKS: RS256, `kid`, issuer, - exactly one audience equal to `OAUTH_NATIVE_MCP_AUDIENCE`, issuer epoch, - `authorization_version` 1, lifetime at most ten minutes, 30 s skew, and - no `act`/`parent_jti` (exchanged tokens cannot be exchanged again). -2. Exchanges it at `/token` (RFC 8693, `client_secret_basic` with the - configured intermediary) for an API-audience token with the parent's - scope. The issuer re-checks grant, registration and membership status on - every exchange, so revocation takes effect on the next request. -3. Verifies the exchanged token: API audience, at most five minutes, not - outliving the parent, same `sub`/`grant_id`/`org_id`/`project_id`, - `client_id` and `act.sub` naming the intermediary, `parent_jti` equal to - the parent `jti`, and scopes no wider than the parent's. -4. Forwards only the exchanged token to the Kernel API. The MCP-audience - token is never sent to the API. - -The request and any stream it opens end after one minute or the exchanged -token's remaining lifetime, whichever is shorter; continuing needs a new -request and a new exchange. Each native request therefore costs one exchange -(one issued-token row) on the API. +`at+jwt` (or `application/at+jwt`). Clerk session tokens (`typ: JWT`) and +opaque API keys keep their existing paths. A native token never falls back to +Clerk or to API-key handling. + +On every request the server: + +1. Verifies the token locally: RS256, issuer, exactly one audience equal to + the MCP resource (`https://mcp.onkernel.com/mcp`), `typ`, profile version, + a lifetime of at most ten minutes with 30 s skew, and no `act` or + `parent_jti` (exchanged tokens cannot be exchanged again). Keys come from + the trusted issuer's `/.well-known/jwks.json`, never from a location named + in the token. The key set is cached for ten minutes and refetched at most + every 30 s when an unknown key id appears. +2. Exchanges it at `/token` (RFC 8693, `client_secret_basic`) for an + API-audience token with `resource=` and the same scope. +3. Verifies the exchanged token and its lineage: API audience, at most five + minutes, same `sub`/`grant_id`/`org_id`/`project_id`, `client_id` and + `act.sub` naming the exchange client, `parent_jti` equal to the incoming + `jti`, expiry no later than the incoming token's, and no wider scope. +4. Calls the Kernel API, starting with `GET /auth/context`, with only the + exchanged token. The MCP-audience token never reaches the API. + +| Outcome | Response | +| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| token fails local verification | 401 `invalid_token` | +| no native issuer trusted for this origin | 401 `invalid_token` | +| issuer answers `invalid_grant` (revoked, expired) | 401 `invalid_token` | +| issuer answers `invalid_scope` | 403 `insufficient_scope` | +| issuer keys or exchange unreachable, 5xx | 503 `temporarily_unavailable` | +| missing exchange credentials, `invalid_target`, `invalid_client`, other OAuth errors, exchanged token outside its lineage | 500 `server_error`, logged | + +### Caching and freshness + +- **Exchanged tokens** are cached in memory, keyed by a SHA-256 fingerprint of + the incoming token (never the token itself), at most 1,000 entries. An entry + is reused only while at least a minute of its lifetime remains, bounded by + both tokens' expiry. Concurrent requests with the same token share one + exchange. The incoming token is verified before the cache is consulted, and + the API checks the exchanged token's status on every call, so revocation + still takes effect on the next request. Losing the cache costs only a new + exchange. +- **`/auth/context`** is resolved on every native request; nothing derived + from it is reused across requests. +- **Identity** for MCP Apps markers, analytics sessions and the entitlements + snapshot is the verified `sub` (and `grant_id` for entitlements), not the + rotating exchanged token. The entitlements snapshot keeps its usual + 30-minute lifetime and only selects which tools are listed; every tool call + is still authorized by the API. +- **Requests and streams** end when the exchanged token expires (at most five + minutes); clients reconnect with a fresh request. ## Configuration -Every value is required when `OAUTH_NATIVE_ENABLED=true`; none has a default. - -| Variable | Staging value | -| -------------------------------- | ---------------------------------------------------------------------- | -| `OAUTH_NATIVE_ISSUER` | `https://auth.dev.onkernel.com` (API `iss`) | -| `OAUTH_NATIVE_MCP_AUDIENCE` | `https://mcp.dev.onkernel.com` (API `NATIVE_OAUTH_MCP_AUDIENCE`) | -| `OAUTH_NATIVE_API_AUDIENCE` | `https://api.dev.onkernel.com` (API `NATIVE_OAUTH_API_AUDIENCE`) | -| `OAUTH_NATIVE_ISSUER_EPOCH` | API `NATIVE_OAUTH_ISSUER_EPOCH` | -| `OAUTH_NATIVE_VERIFICATION_JWKS` | public keys from `https://auth.dev.onkernel.com/.well-known/jwks.json` | -| `OAUTH_NATIVE_EXCHANGE_CLIENT` | confidential registration id (`kcl_…`) | -| `OAUTH_NATIVE_EXCHANGE_SECRET` | its secret (`kcs_…`), staging secret store only | - -Origins must be HTTPS with no path or trailing slash, and the two audiences -must differ. The JWKS must hold only public RSA members with a `kid`. -Keys are pinned rather than fetched, so the API's signing-key rotation -runbook must update this value in its pre-publish step, and keep retired keys -until no token they signed can still be live. `API_BASE_URL` must point at the -API whose native audience is `OAUTH_NATIVE_API_AUDIENCE`. +Trust follows the public MCP origin, the same mapping discovery advertises: + +| MCP origin | Issuer | API | +| ------------------------------ | ------------------------------- | ------------------------------ | +| `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` | + +| Variable | Required | Purpose | +| ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | +| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` | yes | the token-exchange client configured on the authorization server | +| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET` | yes (secret) | its secret | +| `API_BASE_URL` | yes | must be the API origin for this MCP origin; exchanged tokens are valid only there | +| `NATIVE_OAUTH_ISSUER` | only for other origins | issuer for origins outside the table, such as `http://localhost:3002`; ignored for the two above | + +If the exchange credentials are missing or `API_BASE_URL` does not match, +native tokens fail with a 500 and a log line naming the setting; Clerk tokens +and API keys are unaffected. Configuration is read per request, so a build or +an unrelated route never fails on it. On origins outside the table without +`NATIVE_OAUTH_ISSUER`, native tokens are rejected with 401. + +### Provisioning the exchange secret + +The MCP server's runtime configuration lives in its Vercel project's +environment variables, one set per environment (staging serves +`mcp.dev.onkernel.com`, production `mcp.onkernel.com`). For each environment: + +1. Take the exchange client ID and raw secret that the authorization server + for that environment is configured with. Never reuse a secret across + environments or commit it. +2. Add `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` and + `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET` (marked sensitive) to that + environment only, and confirm `API_BASE_URL` is the matching API origin. +3. Redeploy that environment so the new variables take effect. ## Activation prerequisites -Do not enable this anywhere until each item is resolved; production stays off. - -1. **Auth context in policy mode.** Exchanged tokens always run in policy - mode, and every MCP request resolves `GET /auth/context`. On API main that - operation is denied in policy mode, so every native request ends in 403. - kernel/kernel#5309 permits it and must be deployed first. -2. **Tool coverage.** Exchanged tokens reach only the operations listed in - the API's `lib/middleware/scoped_operations.go`; other tools answer 403. - This is not native tool parity with Clerk or API-key callers. -3. **Resource alignment.** MCP discovery advertises the resource - `https://mcp.dev.onkernel.com/mcp`, which MCP clients send as `resource`. - The API accepts only the exact origin `https://mcp.dev.onkernel.com` and - answers `invalid_target` otherwise; a request with no `resource` yields an - API-audience token that this server rejects. One side must accept the - other's identifier deliberately; this server does not rewrite audiences. -4. **Routing.** Staging routes only the CLI natively - (`NATIVE_OAUTH_DEFAULT_NEW_FLOWS=false`), and CLI tokens carry the API - audience. MCP clients receive native MCP-audience tokens only once new - non-CLI flows are routed natively, which also moves every other staging - client. Enable this consumer on staging before that switch. -5. **Intermediary registration.** An operator creates a confidential - registration with only the token-exchange grant - (`Service.CreateConfidentialClient`; there is no HTTP enrollment), sets the - API's `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` to its id, and stores its - secret only in the staging MCP environment. - -## Staging acceptance (not yet run) - -Unit tests cover the contract with locally signed tokens and a mocked issuer -modeled on the deployed API. None of the following has been run: - -1. Fetch protected-resource and authorization-server metadata; register a - public client. -2. Browser PKCE login: Clerk sign-in, organization/project picker, native - consent; redeem the code for an MCP-audience token. -3. `initialize` and `tools/list`; confirm the API sees only the exchanged - token (`act`, `parent_jti`). -4. Call a project-scoped tool that reaches a scoped operation, such as a - browser list, and confirm a different project's resource is denied. -5. Refresh, then revoke the token and the grant; the next request is 401. -6. Hold a stream past one minute; it closes and the client reconnects. -7. Confirm Clerk OAuth and API-key connections behave as before. +1. The authorization server accepts the path-bearing MCP resource + (`/mcp`) as the MCP audience, validates the exchange `resource` + (`invalid_target` otherwise), and has the static exchange client configured. + That server change must be deployed before this one is exercised. +2. The exchange client ID and secret are provisioned in the MCP environment as + above. +3. In staging, route new OAuth flows natively on the authorization server + (`NATIVE_OAUTH_DEFAULT_NEW_FLOWS=true`) and run the acceptance checks + below. Production follows only after staging passes and is approved. + +Exchanged tokens reach only the API operations available to scoped OAuth +tokens; other tools answer 403 until the API allows them. + +## Staging acceptance + +1. Fetch `https://mcp.dev.onkernel.com/.well-known/oauth-protected-resource/mcp`: + `resource` is `https://mcp.dev.onkernel.com/mcp`, `authorization_servers` + is `https://auth.dev.onkernel.com`, and `resource_name` / + `resource_documentation` are present. +2. Connect an MCP client from scratch: dynamic registration, browser PKCE + login and consent. The access token has `typ: at+jwt` and + `aud: ["https://mcp.dev.onkernel.com/mcp"]`. +3. `initialize`, `tools/list` and a project-scoped tool call (for example a + browser list) succeed; a different project's resource is denied. +4. Repeated requests with the same access token perform one exchange, and a + refreshed token performs a new one. +5. On an MCP Apps host, the managed-auth login app opens and its app-only + tools run after a token refresh. +6. Hold a stream open: it closes when the exchanged token expires and the + client reconnects. +7. Revoke the grant: the next request is 401 and the client re-authorizes. +8. Clerk OAuth and API-key connections behave as before. diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index f3f0e2e3..2f961204 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -8,6 +8,7 @@ import { test, } from "bun:test"; import { decodeJwt } from "jose"; +import { clearNativeOAuthCachesForTests } from "@/lib/native-oauth"; import { nativeIssuer } from "@/lib/native-oauth.test-fixtures"; import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; @@ -38,7 +39,10 @@ mock.module("@/lib/mcp/analytics", () => ({ instrumentMcpAnalytics: ( ...[server, client]: Parameters ) => - instrumentMcpAnalytics(server, (analyticsClient as typeof client) ?? client), + instrumentMcpAnalytics( + server, + (analyticsClient as typeof client) ?? client, + ), captureMcpConnectionScopeFailure: ( ...args: Parameters ) => { @@ -47,28 +51,37 @@ mock.module("@/lib/mcp/analytics", () => ({ }, })); -// An in-memory MCP Apps marker store, keyed exactly as Redis keys it. +// An in-memory MCP Apps marker store, keyed exactly as Redis keys it. The +// route records markers through mcp-apps-request's store and the App gate +// reads them through redis, so both are redirected while a test sets it. let appMarkers: Set | null = null; +type Marker = { authSubject: string; transportSessionId: string }; +const markerKey = (marker: Marker) => + mcpAppsMarkerKey(marker.authSubject, marker.transportSessionId); +const memoryMarkers = { + mark: async (marker: Marker) => void appMarkers!.add(markerKey(marker)), + clear: async (marker: Marker) => void appMarkers!.delete(markerKey(marker)), + has: async (marker: Marker) => appMarkers!.has(markerKey(marker)), +}; const redis = await import("@/lib/redis"); -const { markMcpAppsClient, clearMcpAppsClient, hasMcpAppsClient } = redis; -const markerKey = (marker: { - authSubject: string; - transportSessionId: string; -}) => mcpAppsMarkerKey(marker.authSubject, marker.transportSessionId); +const { hasMcpAppsClient } = redis; mock.module("@/lib/redis", () => ({ ...redis, - markMcpAppsClient: async (marker: Parameters[0]) => - appMarkers - ? void appMarkers.add(markerKey(marker)) - : markMcpAppsClient(marker), - clearMcpAppsClient: async ( - marker: Parameters[0], + hasMcpAppsClient: (marker: Parameters[0]) => + appMarkers ? memoryMarkers.has(marker) : hasMcpAppsClient(marker), +})); +const appsRequest = await import("@/lib/mcp-apps-request"); +const { requestUsesMcpApps } = appsRequest; +mock.module("@/lib/mcp-apps-request", () => ({ + ...appsRequest, + requestUsesMcpApps: ( + ...[req, identity, store]: Parameters ) => - appMarkers - ? void appMarkers.delete(markerKey(marker)) - : clearMcpAppsClient(marker), - hasMcpAppsClient: async (marker: Parameters[0]) => - appMarkers ? appMarkers.has(markerKey(marker)) : hasMcpAppsClient(marker), + requestUsesMcpApps( + req, + identity, + store ?? (appMarkers ? memoryMarkers : undefined), + ), })); const originalCreateKernelClient = defaultMcpDependencies.createKernelClient; @@ -117,19 +130,26 @@ afterEach(() => { defaultMcpDependencies.createKernelClient = originalCreateKernelClient; }); +const nativeEnvKeys = Object.keys((await nativeIssuer()).env()); + describe("native OAuth credentials", () => { - const nativeEnv = Object.keys(process.env).filter((key) => - key.startsWith("OAUTH_NATIVE_"), + const envKeys = nativeEnvKeys; + const savedEnv = Object.fromEntries( + envKeys.map((key) => [key, process.env[key]]), ); let issuer: Awaited>; let kernelTokens: string[]; let kernelPaths: string[]; + let browsersRequested: () => void; let fetchSpy: ReturnType>; beforeEach(async () => { + clearNativeOAuthCachesForTests(); issuer = await nativeIssuer(); + Object.assign(process.env, issuer.env()); kernelTokens = []; kernelPaths = []; + browsersRequested = () => {}; defaultMcpDependencies.createKernelClient = (token) => { kernelTokens.push(token); return new Kernel({ @@ -141,6 +161,11 @@ describe("native OAuth credentials", () => { kernelPaths.push(path); if (path === "/org/entitlements") return Response.json({ features: { vaults: { enabled: true } } }); + // A call that outlives the request it belongs to. + if (path === "/browsers") { + browsersRequested(); + return new Promise(() => {}); + } if (path !== "/auth/context") throw new Error(`Unexpected API request: ${path}`); // GET /auth/context as the API answers an exchanged user token. @@ -170,24 +195,11 @@ describe("native OAuth credentials", () => { appMarkers = null; analyticsClient = null; fetchSpy.mockRestore(); - for (const key of Object.keys(process.env)) - if (key.startsWith("OAUTH_NATIVE_") && !nativeEnv.includes(key)) - delete process.env[key]; + for (const key of envKeys) + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; }); - function enable() { - Object.assign(process.env, { - OAUTH_NATIVE_ENABLED: "true", - OAUTH_NATIVE_ISSUER: issuer.config.issuer, - OAUTH_NATIVE_MCP_AUDIENCE: issuer.config.audience, - OAUTH_NATIVE_API_AUDIENCE: issuer.config.apiAudience, - OAUTH_NATIVE_ISSUER_EPOCH: String(issuer.config.epoch), - OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify(issuer.config.keys), - OAUTH_NATIVE_EXCHANGE_CLIENT: issuer.config.clientId, - OAUTH_NATIVE_EXCHANGE_SECRET: issuer.config.clientSecret, - }); - } - function mcpRequest( token: string, method = "tools/list", @@ -206,11 +218,10 @@ describe("native OAuth credentials", () => { }); } - // A refreshed MCP token carries a new jti, so every derived token differs. - const refreshed = () => issuer.parent({ jti: crypto.randomUUID() }); + // Each refresh yields a new MCP token, so every exchanged token differs. + const refreshed = () => issuer.parent(); test("keep MCP Apps capability from initialize across rotated tokens", async () => { - enable(); appMarkers = new Set(); const initialized = await POST( mcpRequest(await refreshed(), "initialize", { @@ -239,23 +250,22 @@ describe("native OAuth credentials", () => { const text = await call.text(); expect(text).toContain('"result"'); expect(text).not.toContain("only available to the secure KERNEL login app"); + expect(issuer.requests).toHaveLength(2); }); test("reuse the entitlements snapshot across rotated tokens", async () => { - enable(); for (let n = 0; n < 2; n++) { const response = await POST(mcpRequest(await refreshed())); expect(response.status).toBe(200); expect(await response.text()).toContain("manage_vaults"); } expect(new Set(kernelTokens).size).toBe(2); - expect(kernelPaths.filter((path) => path === "/org/entitlements")).toEqual( - ["/org/entitlements"], - ); + expect(kernelPaths.filter((path) => path === "/org/entitlements")).toEqual([ + "/org/entitlements", + ]); }); test("keep one analytics session across rotated tokens", async () => { - enable(); const events: { properties: Record }[] = []; analyticsClient = { capture: (event: { properties: Record }) => @@ -273,8 +283,21 @@ describe("native OAuth credentials", () => { ).toBe(1); }); - test("are rejected without contacting the issuer or API while disabled", async () => { - delete process.env.OAUTH_NATIVE_ENABLED; + test("reuse one exchange per MCP token and re-resolve auth context each request", async () => { + const parent = await issuer.parent(); + for (let n = 0; n < 3; n++) { + const response = await POST(mcpRequest(parent, "ping")); + expect(response.status).toBe(200); + await response.text(); + } + expect(issuer.requests).toHaveLength(1); + expect(kernelPaths.filter((path) => path === "/auth/context")).toHaveLength( + 3, + ); + }); + + test("are rejected without contacting the issuer or API on an untrusted origin", async () => { + delete process.env.NATIVE_OAUTH_ISSUER; const response = await POST(mcpRequest(await issuer.parent())); expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toContain( @@ -285,7 +308,6 @@ describe("native OAuth credentials", () => { }); test("reach the API only as the exchanged API-audience token", async () => { - enable(); const parent = await issuer.parent(); const response = await POST(mcpRequest(parent)); expect(response.status).toBe(200); @@ -296,12 +318,11 @@ describe("native OAuth credentials", () => { for (const token of kernelTokens) { expect(token).not.toBe(parent); expect(decodeJwt(token).aud).toEqual([issuer.config.apiAudience]); - expect(decodeJwt(token).parent_jti).toBe("jti_parent"); + expect(decodeJwt(token).parent_jti).toBe(decodeJwt(parent).jti); } }); - test("leave API keys on their existing path when enabled", async () => { - enable(); + test("leave API keys on their existing path", async () => { const response = await POST(mcpRequest("sk_opaque_key")); expect(response.status).toBe(200); expect(fetchSpy).not.toHaveBeenCalled(); @@ -310,44 +331,126 @@ describe("native OAuth credentials", () => { }); test("revocation is 401 and scope rejection 403, never a fallback", async () => { - enable(); - issuer.revoked.add("jti_parent"); - let response = await POST(mcpRequest(await issuer.parent())); + const revokedParent = await issuer.parent(); + issuer.revoked.add(String(decodeJwt(revokedParent).jti)); + let response = await POST(mcpRequest(revokedParent)); expect(response.status).toBe(401); expect((await response.json()).error).toBe("invalid_token"); - fetchSpy.mockImplementation((async () => - Response.json({ error: "invalid_scope" }, { status: 400 })) as never); + fetchSpy.mockImplementation((async (input: string | URL | Request) => + String(input).endsWith("/token") + ? Response.json({ error: "invalid_scope" }, { status: 400 }) + : issuer.request(String(input), {})) as never); response = await POST(mcpRequest(await issuer.parent())); expect(response.status).toBe(403); - expect(response.headers.get("WWW-Authenticate")).toContain( - "insufficient_scope", + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer realm="OAuth", error="insufficient_scope"', ); expect(kernelTokens).toEqual([]); }); - test("issuer outages and misconfiguration are retryable 503s", async () => { - enable(); + test("issuer outages are retryable 503s", async () => { fetchSpy.mockImplementation( (async () => new Response(null, { status: 503 })) as never, ); - let response = await POST(mcpRequest(await issuer.parent())); + const response = await POST(mcpRequest(await issuer.parent())); expect(response.status).toBe(503); expect(response.headers.get("Retry-After")).toBe("1"); - - process.env.OAUTH_NATIVE_API_AUDIENCE = `${issuer.config.apiAudience}/`; - response = await POST(mcpRequest(await issuer.parent())); - expect(response.status).toBe(503); expect(kernelTokens).toEqual([]); }); + test("a missing exchange secret is a server error, never a Clerk fallback", async () => { + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + delete process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET; + const response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(500); + expect((await response.json()).error).toBe("server_error"); + expect(String(errors.mock.calls[0]?.[0])).toContain( + "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(kernelTokens).toEqual([]); + } finally { + errors.mockRestore(); + } + }); + test("post-authentication bugs propagate as server errors", async () => { - enable(); failingAuthContext(new Error("unaccountable")); await expect(POST(mcpRequest(await issuer.parent()))).rejects.toThrow( "Unable to resolve Kernel connection scope", ); }); + + describe("end with the exchanged token", () => { + const shortLived = () => + issuer.parent({ exp: Math.floor(Date.now() / 1000) + 2 }); + + async function closesAt(response: Response, started: number) { + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain( + "text/event-stream", + ); + await response.text(); + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(1_000); + expect(elapsed).toBeLessThan(2_500); + } + + test("a legacy response stream", async () => { + const token = await shortLived(); + const started = Date.now(); + let requested = false; + browsersRequested = () => (requested = true); + await closesAt( + await POST( + mcpRequest(token, "tools/call", { + name: "manage_browsers", + arguments: { action: "list" }, + }), + ), + started, + ); + expect(requested).toBe(true); + }); + + test("a modern subscription stream", async () => { + const token = await shortLived(); + const started = Date.now(); + await closesAt( + await POST( + new nextServer.NextRequest("https://mcp.example.test/mcp", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "subscriptions/listen", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "subscriptions/listen", + params: { + notifications: { toolsListChanged: true }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + name: "modern-client", + version: "1", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }), + ), + started, + ); + }); + }); }); describe("unauthenticated discovery", () => { diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 20123c99..60b5a698 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -8,12 +8,8 @@ import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; import { + authenticateNativeOAuth, isNativeOAuthCredential, - nativeOAuthConfig, - exchangeNativeOAuth, - leaseNativeResponse, - NativeCredentialRejected, - NativeScopeRejected, } from "@/lib/native-oauth"; import { oauthResourceMetadataUrl } from "@/lib/oauth-discovery"; import { @@ -202,6 +198,7 @@ async function handleMcpRequestWithIdentity({ credentialType, transportSessionId, connectionContextCacheIdentity, + entitlementsCacheCredential = token, observeConnection, }: { req: NextRequest; @@ -212,6 +209,7 @@ async function handleMcpRequestWithIdentity({ credentialType: "api_key" | "oauth"; transportSessionId: string | null; connectionContextCacheIdentity?: string; + entitlementsCacheCredential?: string; observeConnection: boolean; }) { const [mcpApps, connection] = await Promise.all([ @@ -247,7 +245,7 @@ async function handleMcpRequestWithIdentity({ authSubject, connection.context.authContext.organization.id, transportSessionId ?? "stateless", - token, + entitlementsCacheCredential, ].join("\0"), }) : { vaults: false, search: false }; @@ -293,50 +291,37 @@ async function handleAuthenticatedRequest( } if (isNativeOAuthCredential(token)) { - let native: Awaited>; - try { - const config = nativeOAuthConfig(); - if (!config) throw new NativeCredentialRejected(); - native = await exchangeNativeOAuth(token, req.signal, config); - } catch (error) { - if (error instanceof NativeScopeRejected) - return errorResponse( - 403, - "insufficient_scope", - "This credential cannot access the requested resource", - { "WWW-Authenticate": 'Bearer error="insufficient_scope"' }, - ); - return error instanceof NativeCredentialRejected - ? createAuthErrorResponse(req) - : errorResponse( - 503, - "temporarily_unavailable", - "Native authorization is unavailable", - { "Retry-After": "1" }, - ); - } - const abort = new AbortController(); - const signal = AbortSignal.any([ - req.signal, - abort.signal, - AbortSignal.timeout(Math.max(1, native.deadline - Date.now())), - ]); - try { - const response = await handleMcpRequestWithIdentity({ - req: new NextRequest(req, { signal }), - token: native.token, - authSubject: `native:${native.subject}`, - scopes: native.scopes, - authInfoExtra: { userId: null, clerkToken: null }, - credentialType: "oauth", - transportSessionId, - observeConnection, - }); - return leaseNativeResponse(response, native.deadline, abort); - } catch (error) { - abort.abort(); - throw error; - } + const native = await authenticateNativeOAuth(token, req); + if (native.status === "misconfigured") + return errorResponse( + 500, + "server_error", + "Kernel OAuth is not configured on this server", + ); + if (native.status !== "ok") + return connectionScopeFailureResponse(req, native); + const { credential } = native; + // The exchanged token rotates; identity is the verified subject, and the + // request ends when the exchanged token does, streams included. + return await handleMcpRequestWithIdentity({ + req: new NextRequest(req, { + signal: AbortSignal.any([ + req.signal, + AbortSignal.timeout(Math.max(1, credential.expiresAt - Date.now())), + ]), + }), + token: credential.token, + authSubject: mcpAppsAuthSubject({ + token: credential.token, + userId: credential.subject, + }), + scopes: credential.scopes, + authInfoExtra: { userId: credential.subject, clerkToken: null }, + credentialType: "oauth", + transportSessionId, + entitlementsCacheCredential: credential.grantId, + observeConnection, + }); } if (!isValidJwtFormat(token)) { diff --git a/src/lib/native-oauth.test-fixtures.ts b/src/lib/native-oauth.test-fixtures.ts index 362243af..206d5280 100644 --- a/src/lib/native-oauth.test-fixtures.ts +++ b/src/lib/native-oauth.test-fixtures.ts @@ -1,21 +1,24 @@ import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from "jose"; -import type { NativeOAuthConfig } from "./native-oauth"; const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; -// Shapes follow the deployed Kernel API issuer (packages/api/lib/oauthnative): -// AccessClaims, Service.Exchange and the RFC 8693 handler in lib/oauthas. -export async function nativeIssuer() { +// A stand-in for the Kernel issuer: it publishes its JWKS and answers RFC +// 8693 token exchange the way the deployed auth server does, including the +// restricted resource profile. +export async function nativeIssuer({ + issuer = "https://auth.example.test", + origin = "https://mcp.example.test", + apiAudience = "https://api.example.test", +} = {}) { const { privateKey, publicKey } = await generateKeyPair("RS256"); const kid = "kid-test"; - const config: NativeOAuthConfig = { - issuer: "https://auth.example.test", - audience: "https://mcp.example.test", - apiAudience: "https://api.example.test", - epoch: 1, + const config = { + issuer, + origin, + audience: `${origin}/mcp`, + apiAudience, clientId: "kcl_mcp", clientSecret: "secret+/=", - keys: { keys: [{ ...(await exportJWK(publicKey)), kid }] }, }; async function sign( @@ -35,7 +38,7 @@ export async function nativeIssuer() { aud: [config.audience], exp: now + 600, iat: now, - jti: "jti_parent", + jti: crypto.randomUUID(), client_id: "kcl_desktop", grant_id: "grant_123", sub_kind: "user", @@ -43,7 +46,7 @@ export async function nativeIssuer() { project_id: "proj_123", scope: "openid email", authorization_version: 1, - issuer_epoch: config.epoch, + issuer_epoch: 1, grant_authorization_epoch: 1, registration_security_version: 1, ...overrides, @@ -52,17 +55,21 @@ export async function nativeIssuer() { const revoked = new Set(); const requests: { url: string; init: RequestInit }[] = []; + const jwksRequests: string[] = []; let derivedOverrides: Record = {}; let responseOverrides: Record = {}; - // Mirrors Service.Exchange: verify the MCP-audience parent, reject - // exchanged parents and inactive grants, narrow scopes, then sign an - // API-audience token living min(5m, parent remaining lifetime). async function request(url: string, init: RequestInit): Promise { - requests.push({ url, init }); const error = (code: string, status = 400) => Response.json({ error: code }, { status }); + if (url === `${config.issuer}/.well-known/jwks.json`) { + jwksRequests.push(url); + return Response.json({ + keys: [{ ...(await exportJWK(publicKey)), kid, alg: "RS256" }], + }); + } if (url !== `${config.issuer}/token`) return error("not_found", 404); + requests.push({ url, init }); const basic = `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`; if (new Headers(init.headers).get("Authorization") !== basic) return error("invalid_client", 401); @@ -74,10 +81,17 @@ export async function nativeIssuer() { form.get("subject_token_type") !== ACCESS_TOKEN_URN || (form.get("requested_token_type") ?? ACCESS_TOKEN_URN) !== ACCESS_TOKEN_URN || - form.has("actor_token") || - form.has("audience") + form.has("actor_token") ) return error("invalid_request"); + // Absent, or exactly one resource naming the API; never an audience. + const resources = form.getAll("resource"); + if ( + form.has("audience") || + resources.length > 1 || + (resources.length === 1 && resources[0] !== config.apiAudience) + ) + return error("invalid_target"); const verified = await jwtVerify( form.get("subject_token")!, async () => publicKey, @@ -129,7 +143,15 @@ export async function nativeIssuer() { sign(parentClaims(overrides)), request, requests, + jwksRequests, revoked, + // Server configuration for an origin outside the known deployments. + env: () => ({ + NATIVE_OAUTH_ISSUER: config.issuer, + API_BASE_URL: config.apiAudience, + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID: config.clientId, + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: config.clientSecret, + }), deriveWith(overrides: Record) { derivedOverrides = overrides; }, diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index d9b73f2d..37a521bb 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -1,21 +1,63 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { exportJWK, generateKeyPair } from "jose"; import { - exchangeNativeOAuth, + afterEach, + beforeEach, + describe, + expect, + setSystemTime, + spyOn, + test, +} from "bun:test"; +import { decodeJwt, generateKeyPair, SignJWT } from "jose"; +import { + authenticateNativeOAuth, + clearNativeOAuthCachesForTests, isNativeOAuthCredential, - leaseNativeResponse, - NATIVE_STREAM_LEASE_MS, - nativeOAuthConfig, - NativeCredentialRejected, - NativeScopeRejected, } from "./native-oauth"; import { nativeIssuer } from "./native-oauth.test-fixtures"; -const signal = () => new AbortController().signal; +const ENV_KEYS = [ + "API_BASE_URL", + "NATIVE_OAUTH_ISSUER", + "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID", + "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", +]; +const savedEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +); + +let issuer: Awaited>; +let fetchSpy: ReturnType>; + +function useIssuer(next: typeof issuer) { + issuer = next; + for (const key of ENV_KEYS) delete process.env[key]; + Object.assign(process.env, issuer.env()); +} + +const mcpRequest = (origin = issuer.config.origin) => + new Request(`${origin}/mcp`, { method: "POST" }); +const authenticate = async (token: string, request = mcpRequest()) => + authenticateNativeOAuth(token, request); + +beforeEach(async () => { + clearNativeOAuthCachesForTests(); + useIssuer(await nativeIssuer()); + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async ( + input: string | URL | Request, + init?: RequestInit, + ) => issuer.request(String(input), init ?? {})) as typeof fetch); +}); + +afterEach(() => { + setSystemTime(); + fetchSpy.mockRestore(); + for (const key of ENV_KEYS) + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; +}); describe("native credential detection", () => { test("selects only at+jwt tokens, as the Kernel API does", async () => { - const issuer = await nativeIssuer(); expect(isNativeOAuthCredential(await issuer.parent())).toBe(true); expect( isNativeOAuthCredential( @@ -37,92 +79,115 @@ describe("native credential detection", () => { }); describe("native token exchange", () => { - test("exchanges every request with the issuer's RFC 8693 contract", async () => { - const issuer = await nativeIssuer(); + test("exchanges for the API audience with the RFC 8693 contract", async () => { const parent = await issuer.parent(); - for (let n = 0; n < 2; n++) { - const result = await exchangeNativeOAuth( - parent, - signal(), - issuer.config, - issuer.request, - ); - expect(result.token).not.toBe(parent); - expect(result.subject).toBe("user_123"); - expect(result.scopes).toEqual(["openid", "email"]); - } - expect(issuer.requests).toHaveLength(2); + const result = await authenticate(parent); + if (result.status !== "ok") throw new Error(result.status); + expect(result.credential.subject).toBe("user_123"); + expect(result.credential.grantId).toBe("grant_123"); + expect(result.credential.scopes).toEqual(["openid", "email"]); + const derived = decodeJwt(result.credential.token); + expect(derived.aud).toEqual([issuer.config.apiAudience]); + expect(derived.parent_jti).toBe(decodeJwt(parent).jti); + + expect(issuer.jwksRequests).toEqual([ + "https://auth.example.test/.well-known/jwks.json", + ]); + expect(issuer.requests).toHaveLength(1); const { url, init } = issuer.requests[0]!; expect(url).toBe("https://auth.example.test/token"); expect(init.redirect).toBe("error"); expect(init.cache).toBe("no-store"); + expect(new Headers(init.headers).get("Authorization")).toBe( + `Basic ${Buffer.from("kcl_mcp:secret%2B%2F%3D").toString("base64")}`, + ); expect(Object.fromEntries(new URLSearchParams(String(init.body)))).toEqual({ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token: parent, subject_token_type: "urn:ietf:params:oauth:token-type:access_token", requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + resource: "https://api.example.test", scope: "openid email", }); }); - test("accepts five-minute exchanged tokens but leases one minute", async () => { - const issuer = await nativeIssuer(); - const started = Date.now(); - const result = await exchangeNativeOAuth( - await issuer.parent(), - signal(), - issuer.config, - issuer.request, - ); - expect(result.deadline).toBeGreaterThanOrEqual( - started + NATIVE_STREAM_LEASE_MS, + test("reuses one exchange per incoming token until a minute before expiry", async () => { + const now = Date.now(); + setSystemTime(now); + const parent = await issuer.parent(); + const first = await authenticate(parent); + const second = await authenticate(parent); + expect(first.status).toBe("ok"); + expect(second).toEqual(first); + expect(issuer.requests).toHaveLength(1); + + // A different incoming token is a different cache entry. + await authenticate(await issuer.parent()); + expect(issuer.requests).toHaveLength(2); + + // The 300s exchanged token is reused for 240s, then replaced. + setSystemTime(now + 239_000); + expect(await authenticate(parent)).toEqual(first); + setSystemTime(now + 241_000); + const renewed = await authenticate(parent); + if (renewed.status !== "ok" || first.status !== "ok") + throw new Error("not ok"); + expect(renewed.credential.token).not.toBe(first.credential.token); + expect(issuer.requests).toHaveLength(3); + // Local verification still runs on every request; the JWKS is cached. + expect(issuer.jwksRequests).toHaveLength(1); + }); + + test("coalesces concurrent exchanges of the same token", async () => { + const parent = await issuer.parent(); + const results = await Promise.all( + Array.from({ length: 5 }, () => authenticate(parent)), ); - expect(result.deadline).toBeLessThanOrEqual( - Date.now() + NATIVE_STREAM_LEASE_MS, + expect(new Set(results.map((result) => JSON.stringify(result))).size).toBe( + 1, ); + expect(issuer.requests).toHaveLength(1); }); - test("bounds the lease by a shorter remaining parent lifetime despite issuer skew", async () => { - const issuer = await nativeIssuer(); - const skew = 20; - const now = Math.floor(Date.now() / 1000) + skew; - // The issuer derives the remaining lifetime from its own clock; only the - // relative lifetime bounds the local lease. - issuer.deriveWith({ iat: now, exp: now + 15 }); - issuer.respondWith({ expires_in: 15 }); + test("does not cache a token with under a minute left, and bounds its lifetime", async () => { + const parent = await issuer.parent({ + exp: Math.floor(Date.now() / 1000) + 20, + }); const started = Date.now(); - const result = await exchangeNativeOAuth( - await issuer.parent({ exp: now + 15 }), - signal(), - issuer.config, - issuer.request, - ); - expect(result.deadline).toBeGreaterThanOrEqual(started + 15000); - expect(result.deadline).toBeLessThanOrEqual(Date.now() + 15000); + const first = await authenticate(parent); + if (first.status !== "ok") throw new Error(first.status); + expect(first.credential.expiresAt).toBeGreaterThanOrEqual(started + 19_000); + expect(first.credential.expiresAt).toBeLessThanOrEqual(Date.now() + 20_000); + await authenticate(parent); + expect(issuer.requests).toHaveLength(2); }); - test("a parent at the end of its life is rejected for refresh", async () => { - const issuer = await nativeIssuer(); - issuer.respondWith({ expires_in: 0 }); - await expect( - exchangeNativeOAuth( - await issuer.parent(), - signal(), - issuer.config, - issuer.request, - ), - ).rejects.toBeInstanceOf(NativeCredentialRejected); + test("bounds the lifetime by the issuer's relative expiry despite clock skew", async () => { + const skewed = Math.floor(Date.now() / 1000) + 20; + issuer.deriveWith({ iat: skewed, exp: skewed + 15 }); + issuer.respondWith({ expires_in: 15 }); + const started = Date.now(); + const result = await authenticate( + await issuer.parent({ exp: skewed + 15 }), + ); + if (result.status !== "ok") throw new Error(result.status); + expect(result.credential.expiresAt).toBeGreaterThanOrEqual( + started + 15_000, + ); + expect(result.credential.expiresAt).toBeLessThanOrEqual( + Date.now() + 15_000, + ); }); - test("rejects parents outside the issuer profile before any network call", async () => { - const issuer = await nativeIssuer(); + test("rejects tokens outside the issuer profile before any exchange", async () => { const now = Math.floor(Date.now() / 1000); const other = await generateKeyPair("RS256"); const rejected = [ await issuer.parent({ aud: [issuer.config.apiAudience] }), + // The resource is the path-bearing MCP endpoint, not the bare origin. + await issuer.parent({ aud: [issuer.config.origin] }), await issuer.parent({ aud: [issuer.config.audience, "https://x.test"] }), await issuer.parent({ iss: "https://other.example.test" }), - await issuer.parent({ issuer_epoch: 2 }), await issuer.parent({ authorization_version: 2 }), await issuer.parent({ iat: now - 700, exp: now + 1 }), await issuer.parent({ iat: now - 120, exp: now - 60 }), @@ -132,235 +197,215 @@ describe("native token exchange", () => { parent_jti: "jti_root", }), await issuer.sign(issuer.parentClaims(), { typ: "JWT" }), - await issuer.sign(issuer.parentClaims(), { kid: "unknown" }), - await issuer.sign(issuer.parentClaims(), { kid: undefined }), + await new SignJWT(issuer.parentClaims()) + .setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "kid-test" }) + .sign(other.privateKey), await issuer.parent({ pad: "x".repeat(4096) }), ]; for (const token of rejected) - await expect( - exchangeNativeOAuth(token, signal(), issuer.config, issuer.request), - ).rejects.toBeInstanceOf(NativeCredentialRejected); - await expect( - exchangeNativeOAuth( - await issuer.parent(), - signal(), - { - ...issuer.config, - keys: { - keys: [{ ...(await exportJWK(other.publicKey)), kid: "kid-test" }], - }, - }, - issuer.request, - ), - ).rejects.toBeInstanceOf(NativeCredentialRejected); + expect(await authenticate(token)).toEqual({ + status: "rejected", + statusCode: 401, + }); expect(issuer.requests).toEqual([]); }); - test("revocation is a credential rejection, never a fallback", async () => { - const issuer = await nativeIssuer(); - issuer.revoked.add("jti_parent"); - await expect( - exchangeNativeOAuth( - await issuer.parent(), - signal(), - issuer.config, - issuer.request, - ), - ).rejects.toBeInstanceOf(NativeCredentialRejected); - expect(issuer.requests).toHaveLength(1); + test("never fetches keys from a location named by the token", async () => { + const token = await issuer.sign(issuer.parentClaims(), { + jku: "https://evil.example.test/jwks.json", + }); + expect((await authenticate(token)).status).toBe("ok"); + expect(fetchSpy.mock.calls.map(([url]) => String(url))).not.toContain( + "https://evil.example.test/jwks.json", + ); }); - test("scope rejection is permanent; other issuer failures are retryable", async () => { - const issuer = await nativeIssuer(); - const parent = await issuer.parent(); - const cases: [Response, unknown][] = [ + test("an unreachable JWKS is unavailable, not a rejected credential", async () => { + fetchSpy.mockImplementation((async () => { + throw new TypeError("fetch failed"); + }) as never); + expect(await authenticate(await issuer.parent())).toEqual({ + status: "unavailable", + }); + }); + + test("maps the issuer's OAuth errors", async () => { + const cases: [() => Promise, unknown][] = [ [ - Response.json({ error: "invalid_scope" }, { status: 400 }), - NativeScopeRejected, + async () => Response.json({ error: "invalid_grant" }, { status: 400 }), + { status: "rejected", statusCode: 401 }, ], [ - Response.json({ error: "invalid_grant" }, { status: 400 }), - NativeCredentialRejected, + async () => Response.json({ error: "invalid_scope" }, { status: 400 }), + { status: "rejected", statusCode: 403 }, ], - [Response.json({ error: "unauthorized_client" }, { status: 400 }), Error], - [Response.json({ error: "invalid_client" }, { status: 401 }), Error], [ - Response.json({ error: "temporarily_unavailable" }, { status: 503 }), - Error, + async () => Response.json({ error: "invalid_target" }, { status: 400 }), + { status: "misconfigured" }, + ], + [ + async () => + Response.json({ error: "unauthorized_client" }, { status: 400 }), + { status: "misconfigured" }, + ], + [ + async () => Response.json({ error: "invalid_client" }, { status: 401 }), + { status: "misconfigured" }, + ], + [ + async () => + Response.json({ error: "temporarily_unavailable" }, { status: 503 }), + { status: "unavailable" }, + ], + [ + async () => new Response("", { status: 502 }), + { status: "unavailable" }, + ], + [ + async () => { + throw new TypeError("fetch failed"); + }, + { status: "unavailable" }, ], - [new Response("", { status: 400 }), Error], ]; - for (const [response, kind] of cases) { - const failure = await exchangeNativeOAuth( - parent, - signal(), - issuer.config, - async () => response, - ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(kind as typeof Error); - if (kind === Error) { - expect(failure).not.toBeInstanceOf(NativeCredentialRejected); - expect(failure).not.toBeInstanceOf(NativeScopeRejected); - } + // Prime the JWKS so only the exchange fails. + await authenticate(await issuer.parent()); + for (const [respond, expected] of cases) { + const parent = await issuer.parent(); + fetchSpy.mockImplementation((async (input: string | URL | Request) => + String(input).endsWith("/token") + ? respond() + : issuer.request(String(input), {})) as never); + expect(await authenticate(parent)).toEqual(expected as never); } }); - test("a derived token outside the exchange lineage fails closed", async () => { - const issuer = await nativeIssuer(); + test("a revoked grant is rejected and never retried elsewhere", async () => { const parent = await issuer.parent(); - const now = Math.floor(Date.now() / 1000); - const broken: [Record, Record][] = [ - [{ aud: [issuer.config.audience] }, {}], - [{ iat: now, exp: now + 301 }, {}], - [{ exp: now + 601, iat: now + 400 }, {}], - [{ sub: "user_other" }, {}], - [{ grant_id: "grant_other" }, {}], - [{ org_id: "org_other" }, {}], - [{ project_id: undefined }, {}], - [{ client_id: "kcl_other" }, {}], - [{ act: { sub: "oauth_client:kcl_other" } }, {}], - [{ act: undefined }, {}], - [{ parent_jti: "jti_other" }, {}], - [{ issuer_epoch: 2 }, {}], - [{ scope: "openid email admin" }, { scope: "openid email admin" }], - [{}, { scope: "openid" }], - [{}, { issued_token_type: "urn:ietf:params:oauth:token-type:jwt" }], - ]; - for (const [derived, response] of broken) { - issuer.deriveWith(derived); - issuer.respondWith(response); - const failure = await exchangeNativeOAuth( - parent, - signal(), - issuer.config, - issuer.request, - ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(Error); - expect(failure).not.toBeInstanceOf(NativeCredentialRejected); - } + issuer.revoked.add(String(decodeJwt(parent).jti)); + expect(await authenticate(parent)).toEqual({ + status: "rejected", + statusCode: 401, + }); + expect(issuer.requests).toHaveLength(1); }); - test("the exchange request ends with the client request", async () => { - const issuer = await nativeIssuer(); - const abort = new AbortController(); - abort.abort(); - let seen: AbortSignal | undefined; - await expect( - exchangeNativeOAuth( - await issuer.parent(), - abort.signal, - issuer.config, - async (_url, init) => { - seen = init.signal ?? undefined; - throw new Error("aborted"); - }, + test("an API audience the issuer does not serve is invalid_target", async () => { + process.env.API_BASE_URL = "https://api.other.test"; + expect(await authenticate(await issuer.parent())).toEqual({ + status: "misconfigured", + }); + expect( + new URLSearchParams(String(issuer.requests[0]!.init.body)).get( + "resource", ), - ).rejects.toThrow("aborted"); - expect(seen?.aborted).toBe(true); - }); -}); - -describe("native configuration", () => { - const saved = { ...process.env }; - afterEach(() => { - for (const key of Object.keys(process.env)) - if (key.startsWith("OAUTH_NATIVE_")) delete process.env[key]; - Object.assign(process.env, saved); + ).toBe("https://api.other.test"); }); - async function enable(overrides: Record = {}) { - const { publicKey } = await generateKeyPair("RS256"); - Object.assign(process.env, { - OAUTH_NATIVE_ENABLED: "true", - OAUTH_NATIVE_ISSUER: "https://auth.dev.example.test", - OAUTH_NATIVE_MCP_AUDIENCE: "https://mcp.dev.example.test", - OAUTH_NATIVE_API_AUDIENCE: "https://api.dev.example.test", - OAUTH_NATIVE_ISSUER_EPOCH: "1", - OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify({ - keys: [ - { - ...(await exportJWK(publicKey)), - kid: "k1", - alg: "RS256", - use: "sig", - }, + test("an exchanged token outside the lineage fails closed and names the check", async () => { + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + const now = Math.floor(Date.now() / 1000); + const broken: [ + Record, + Record, + string, + ][] = [ + [{ aud: [issuer.config.audience] }, {}, "aud"], + [{ iat: now, exp: now + 301 }, {}, "lifetime"], + [{ sub: "user_other" }, {}, "sub"], + [{ grant_id: "grant_other" }, {}, "grant_id"], + [{ org_id: "org_other" }, {}, "org_id"], + [{ project_id: undefined }, {}, "project_id"], + [{ client_id: "kcl_other" }, {}, "client_id"], + [{ act: { sub: "oauth_client:kcl_other" } }, {}, "act"], + [{ act: undefined }, {}, "act"], + [{ parent_jti: "jti_other" }, {}, "parent_jti"], + [{ exp: now + 601, iat: now + 400 }, {}, "exp"], + [ + { scope: "openid email admin" }, + { scope: "openid email admin" }, + "scope", ], - }), - OAUTH_NATIVE_EXCHANGE_CLIENT: "kcl_mcp", - OAUTH_NATIVE_EXCHANGE_SECRET: "secret", - }); - for (const [key, value] of Object.entries(overrides)) - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - - test("is off without any native settings", () => { - delete process.env.OAUTH_NATIVE_ENABLED; - expect(nativeOAuthConfig()).toBeNull(); - process.env.OAUTH_NATIVE_ENABLED = "false"; - expect(nativeOAuthConfig()).toBeNull(); + [{}, { scope: "openid" }, "scope"], + [ + {}, + { issued_token_type: "urn:ietf:params:oauth:token-type:jwt" }, + "RFC 8693", + ], + ]; + for (const [derived, response, check] of broken) { + issuer.deriveWith(derived); + issuer.respondWith(response); + errors.mockClear(); + expect(await authenticate(await issuer.parent())).toEqual({ + status: "misconfigured", + }); + expect(String(errors.mock.calls[0]?.[0])).toContain(check); + } + } finally { + errors.mockRestore(); + } }); +}); - test("pins every trust input when enabled", async () => { - await enable(); - const config = nativeOAuthConfig()!; - expect(config.issuer).toBe("https://auth.dev.example.test"); - expect(config.audience).toBe("https://mcp.dev.example.test"); - expect(config.apiAudience).toBe("https://api.dev.example.test"); - expect(Object.keys(config.keys.keys[0]!).sort()).toEqual([ - "e", - "kid", - "kty", - "n", +describe("native configuration", () => { + test("derives issuer, audiences and API from the public MCP origin", async () => { + useIssuer( + await nativeIssuer({ + issuer: "https://auth.dev.onkernel.com", + origin: "https://mcp.dev.onkernel.com", + apiAudience: "https://api.dev.onkernel.com", + }), + ); + // The override applies only outside the known deployments. + process.env.NATIVE_OAUTH_ISSUER = "https://auth.example.test"; + const result = await authenticate( + await issuer.parent(), + new Request("http://localhost:3002/mcp", { + headers: { Host: "mcp.dev.onkernel.com" }, + }), + ); + expect(result.status).toBe("ok"); + expect(issuer.jwksRequests).toEqual([ + "https://auth.dev.onkernel.com/.well-known/jwks.json", ]); + expect(issuer.requests[0]!.url).toBe("https://auth.dev.onkernel.com/token"); }); - test.each([ - ["OAUTH_NATIVE_ISSUER", undefined], - ["OAUTH_NATIVE_ISSUER", "http://auth.dev.example.test"], - ["OAUTH_NATIVE_MCP_AUDIENCE", "https://mcp.dev.example.test/mcp"], - ["OAUTH_NATIVE_API_AUDIENCE", "https://api.dev.example.test/"], - ["OAUTH_NATIVE_API_AUDIENCE", "https://mcp.dev.example.test"], - ["OAUTH_NATIVE_ISSUER_EPOCH", "0"], - ["OAUTH_NATIVE_VERIFICATION_JWKS", undefined], - ["OAUTH_NATIVE_VERIFICATION_JWKS", '{"keys":[]}'], - ["OAUTH_NATIVE_EXCHANGE_SECRET", undefined], - ])("rejects %s=%s", async (key, value) => { - await enable({ [key]: value }); - expect(() => nativeOAuthConfig()).toThrow(); + test("trusts no native issuer on other origins without an override", async () => { + delete process.env.NATIVE_OAUTH_ISSUER; + expect(await authenticate(await issuer.parent())).toEqual({ + status: "rejected", + statusCode: 401, + }); + expect(fetchSpy).not.toHaveBeenCalled(); }); - test("refuses private key material in the verification set", async () => { - const { privateKey } = await generateKeyPair("RS256", { - extractable: true, - }); - await enable({ - OAUTH_NATIVE_VERIFICATION_JWKS: JSON.stringify({ - keys: [{ ...(await exportJWK(privateKey)), kid: "k1" }], - }), + test.each([ + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID", undefined], + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", undefined], + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", ""], + ["API_BASE_URL", undefined], + ["NATIVE_OAUTH_ISSUER", "http://auth.example.test"], + ["NATIVE_OAUTH_ISSUER", "https://auth.example.test/"], + ])("fails closed without network when %s=%s", async (key, value) => { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + expect(await authenticate(await issuer.parent())).toEqual({ + status: "misconfigured", }); - expect(() => nativeOAuthConfig()).toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); }); -}); -describe("native stream lease", () => { - test("closes a stream when its authorization lease ends", async () => { - let cancelled = false; - const abort = new AbortController(); - const response = leaseNativeResponse( - new Response( - new ReadableStream({ - cancel() { - cancelled = true; - }, - }), + test("refuses to send a known deployment's tokens to another API", async () => { + process.env.API_BASE_URL = "https://api.onkernel.com"; + expect( + await authenticate( + await issuer.parent(), + new Request("https://mcp.dev.onkernel.com/mcp"), ), - Date.now() + 20, - abort, - ); - const result = await response.body!.getReader().read(); - expect(result.done).toBe(true); - expect(abort.signal.aborted).toBe(true); - expect(cancelled).toBe(true); + ).toEqual({ status: "misconfigured" }); + expect(fetchSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index ecedd986..b30b119a 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -1,27 +1,31 @@ +import { createHash } from "node:crypto"; import { - createLocalJWKSet, + createRemoteJWKSet, decodeProtectedHeader, + errors, jwtVerify, - type JSONWebKeySet, + type JWTVerifyGetKey, } from "jose"; import { z } from "zod"; +import { kernelDeployment, oauthRequestOrigin } from "@/lib/oauth-discovery"; -// Mirrors the Kernel API issuer profile (packages/api/lib/oauthnative): -// MaxTokenBytes, AccessTokenLifetime, ExchangeTokenLifetime and the -// signature leeway. +// The Kernel issuer's access-token profile: at most 4 KiB, MCP-audience +// tokens live ten minutes, exchanged API-audience tokens five. const MAX_TOKEN_BYTES = 4096; const ACCESS_TOKEN_LIFETIME_SECONDS = 600; const EXCHANGE_TOKEN_LIFETIME_SECONDS = 300; const CLOCK_TOLERANCE_SECONDS = 30; -const ACCESS_TOKEN_TYPES = ["at+jwt", "application/at+jwt"]; +const ACCESS_TOKEN_TYPE = "at+jwt"; const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; +const JWKS_PATH = "/.well-known/jwks.json"; +const EXCHANGE_TIMEOUT_MS = 5_000; -// A native request or stream is authorized for at most one minute, however -// long the exchanged token lives; continuing requires a fresh exchange. -export const NATIVE_STREAM_LEASE_MS = 60_000; +// A derived token is reused only while at least this much of its lifetime +// remains, so every request gets a usable window and clock skew is covered. +const DERIVED_TOKEN_MIN_REMAINING_MS = 60_000; +const MAX_DERIVED_TOKENS = 1_000; const accessClaimsSchema = z.object({ - iss: z.string(), sub: z.string().min(1), aud: z.array(z.string()).length(1), jti: z.string().min(1), @@ -33,7 +37,6 @@ const accessClaimsSchema = z.object({ project_id: z.string().optional(), scope: z.string().min(1), authorization_version: z.literal(1), - issuer_epoch: z.number().int(), act: z.object({ sub: z.string() }).optional(), parent_jti: z.string().optional(), }); @@ -47,262 +50,394 @@ const exchangeSchema = z.object({ scope: z.string().min(1), }); -const jwksSchema = z.object({ - keys: z - .array( - z.object({ - kty: z.literal("RSA"), - kid: z.string().min(1), - n: z.string().min(1), - e: z.string().min(1), - d: z.undefined(), - }), - ) - .min(1), -}); +export type NativeCredential = { + // The exchanged API-audience token; the only token sent to the Kernel API. + token: string; + subject: string; + grantId: string; + scopes: string[]; + // When the exchanged token stops being valid, in local time. + expiresAt: number; +}; + +export type NativeOAuthResult = + | { status: "ok"; credential: NativeCredential } + | { status: "rejected"; statusCode: 401 | 403 } + | { status: "unavailable" } + | { status: "misconfigured" }; + +type NativeOAuthFailure = Exclude; + +class NativeOAuthError extends Error { + constructor( + readonly failure: NativeOAuthFailure, + message: string, + ) { + super(message); + } +} -// Matches the API's own selector: a compact JWT whose header typ is at+jwt. -// Clerk session tokens carry typ JWT and keep their existing path. +const rejected = (message: string) => + new NativeOAuthError({ status: "rejected", statusCode: 401 }, message); +const unavailable = (message: string) => + new NativeOAuthError({ status: "unavailable" }, message); +const misconfigured = (message: string) => + new NativeOAuthError({ status: "misconfigured" }, message); + +// Matches the Kernel API's own selector: a compact JWT whose header typ is +// at+jwt. Clerk session tokens carry typ JWT and keep their existing path. export function isNativeOAuthCredential(token: string): boolean { if (token.split(".").length !== 3) return false; try { const typ = decodeProtectedHeader(token).typ?.toLowerCase(); - return typ !== undefined && ACCESS_TOKEN_TYPES.includes(typ); + return ( + typ === ACCESS_TOKEN_TYPE || typ === `application/${ACCESS_TOKEN_TYPE}` + ); } catch { return false; } } -export class NativeCredentialRejected extends Error { - constructor() { - super("native credential rejected"); - } -} - -export class NativeScopeRejected extends Error { - constructor() { - super("native scope rejected"); - } -} - -export type NativeOAuthConfig = { +type NativeOAuthConfig = { issuer: string; audience: string; apiAudience: string; - epoch: number; clientId: string; clientSecret: string; - keys: JSONWebKeySet; + keys: JWTVerifyGetKey; }; -function httpsOrigin(name: string): string { - const value = process.env[name]; - if (!value || new URL(value).protocol !== "https:") - throw new Error(`${name} must be an HTTPS origin`); - if (new URL(value).origin !== value) - throw new Error(`${name} must be an origin without a trailing slash`); +const keySets = new Map(); + +// Keys come only from the configured issuer's JWKS, never from a URL in the +// token. jose caches the set, refetches for an unknown kid at most every 30s, +// and bounds each fetch to five seconds. +function issuerKeys(issuer: string): JWTVerifyGetKey { + let keys = keySets.get(issuer); + if (!keys) { + keys = createRemoteJWKSet(new URL(JWKS_PATH, issuer), { + timeoutDuration: 5_000, + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + }); + keySets.set(issuer, keys); + } + return keys; +} + +function configuredOrigin(name: string, value: string): string { + const url = URL.canParse(value) ? new URL(value) : null; + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes( + url?.hostname ?? "", + ); + if ( + !url || + url.origin !== value || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) + throw misconfigured(`${name} must be an HTTPS or loopback origin`); return value; } -// Returns null unless explicitly enabled. Every trust input is required and -// has no default, so an enabled deployment cannot silently trust another -// environment's issuer. -export function nativeOAuthConfig(): NativeOAuthConfig | null { - if (process.env.OAUTH_NATIVE_ENABLED !== "true") return null; - const issuer = httpsOrigin("OAUTH_NATIVE_ISSUER"); - const audience = httpsOrigin("OAUTH_NATIVE_MCP_AUDIENCE"); - const apiAudience = httpsOrigin("OAUTH_NATIVE_API_AUDIENCE"); - if (audience === apiAudience) - throw new Error("native OAuth MCP and API audiences must differ"); - const epoch = Number(process.env.OAUTH_NATIVE_ISSUER_EPOCH); - const clientId = process.env.OAUTH_NATIVE_EXCHANGE_CLIENT; - const clientSecret = process.env.OAUTH_NATIVE_EXCHANGE_SECRET; - if (!clientId || !clientSecret || !Number.isSafeInteger(epoch) || epoch < 1) - throw new Error("incomplete native OAuth configuration"); +// Trust is derived from the public MCP origin, the same mapping discovery +// advertises. NATIVE_OAUTH_ISSUER exists only for origins outside that +// mapping, such as local development. Returns null when this origin trusts +// no native issuer. +function nativeOAuthConfig(request: Request): NativeOAuthConfig | null { + const origin = oauthRequestOrigin(request); + const deployment = kernelDeployment(origin); + const issuerOverride = process.env.NATIVE_OAUTH_ISSUER; + if (!deployment && !issuerOverride) return null; + const issuer = + deployment?.authorizationServer ?? + configuredOrigin("NATIVE_OAUTH_ISSUER", issuerOverride!); + + // Exchanged tokens are only valid at the API they were issued for, so the + // API this server calls must be that audience. + const apiBaseUrl = process.env.API_BASE_URL; + const apiOrigin = + apiBaseUrl && URL.canParse(apiBaseUrl) ? new URL(apiBaseUrl).origin : null; + const apiAudience = deployment?.api ?? apiOrigin; + if (!apiAudience || apiOrigin !== apiAudience) + throw misconfigured(`API_BASE_URL must be ${apiAudience ?? "set"}`); + + const clientId = process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID; + const clientSecret = process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET; + if (!clientId || !clientSecret) + throw misconfigured( + "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID and NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET are required", + ); + return { issuer, - audience, + audience: `${origin}/mcp`, apiAudience, - epoch, clientId, clientSecret, - keys: jwksSchema.parse( - JSON.parse(process.env.OAUTH_NATIVE_VERIFICATION_JWKS ?? ""), - ), + keys: issuerKeys(issuer), }; } +const REJECTED_TOKEN_ERRORS = [ + errors.JWTClaimValidationFailed, + errors.JWTExpired, + errors.JWSSignatureVerificationFailed, + errors.JWSInvalid, + errors.JWTInvalid, + errors.JWKSNoMatchingKey, + errors.JWKSMultipleMatchingKeys, + errors.JOSEAlgNotAllowed, + errors.JOSENotSupported, +]; + +// Throws NativeOAuthError: a token outside the profile is rejected, while a +// failure to fetch the issuer's keys is unavailable. async function verifyAccessToken( token: string, audience: string, maxLifetimeSeconds: number, config: NativeOAuthConfig, ): Promise { - const { payload, protectedHeader } = await jwtVerify( - token, - createLocalJWKSet(config.keys), - { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, config.keys, { issuer: config.issuer, audience, algorithms: ["RS256"], + typ: ACCESS_TOKEN_TYPE, clockTolerance: CLOCK_TOLERANCE_SECONDS, requiredClaims: ["iat", "exp"], - }, + })); + } catch (error) { + if (REJECTED_TOKEN_ERRORS.some((type) => error instanceof type)) + throw rejected( + `native access token rejected: ${(error as Error).message}`, + ); + throw unavailable( + `native issuer keys unavailable: ${error instanceof Error ? error.message : error}`, + ); + } + const claims = accessClaimsSchema.safeParse(payload); + if (!claims.success) + throw rejected("native access token is outside the issuer profile"); + const lifetime = claims.data.exp - claims.data.iat; + if (lifetime <= 0 || lifetime > maxLifetimeSeconds) + throw rejected("native access token lifetime is outside the profile"); + return claims.data; +} + +async function readExchangeResponse(response: Response): Promise { + let text: string; + try { + text = await response.text(); + } catch (error) { + throw unavailable( + `native exchange failed: ${error instanceof Error ? error.message : error}`, + ); + } + if (text.length > 16_384) + throw unavailable("native exchange response too large"); + try { + return JSON.parse(text); + } catch { + throw unavailable(`native exchange returned HTTP ${response.status}`); + } +} + +// RFC 8693 errors from the issuer: an inactive grant rejects the credential, +// a scope it no longer covers is forbidden, and anything that names this +// server's request or client is a deployment fault no retry will fix. +function exchangeFailure(status: number, body: unknown): NativeOAuthError { + const error = z.object({ error: z.string() }).safeParse(body).data?.error; + if (error === "invalid_grant") + return rejected("native exchange: grant inactive"); + if (error === "invalid_scope") + return new NativeOAuthError( + { status: "rejected", statusCode: 403 }, + "native exchange: scope not granted", + ); + if (status >= 500 || error === "temporarily_unavailable") + return unavailable(`native exchange unavailable: HTTP ${status}`); + return misconfigured( + `native exchange refused this server: HTTP ${status} ${error ?? "without an OAuth error"}`, ); - const claims = accessClaimsSchema.parse(payload); - if ( - !protectedHeader.kid || - !ACCESS_TOKEN_TYPES.includes(protectedHeader.typ?.toLowerCase() ?? "") || - claims.issuer_epoch !== config.epoch || - claims.exp - claims.iat <= 0 || - claims.exp - claims.iat > maxLifetimeSeconds - ) - throw new Error("invalid native access token"); - return claims; } -// All HTTP destinations and verification keys come from server configuration, never JWT claims. -export async function exchangeNativeOAuth( +type CachedCredential = NativeCredential & { reuseUntil: number }; + +async function exchange( token: string, - signal: AbortSignal, + parent: AccessClaims, config: NativeOAuthConfig, - request: (url: string, init: RequestInit) => Promise = fetch, -) { - if (token.length > MAX_TOKEN_BYTES) throw new NativeCredentialRejected(); - const parent = await verifyAccessToken( - token, - config.audience, - ACCESS_TOKEN_LIFETIME_SECONDS, - config, - ).catch(() => { - throw new NativeCredentialRejected(); - }); - // Exchanged tokens cannot be exchanged again, and only the issuer's - // authoritative status check (performed by the exchange) admits a parent. - if (parent.act || parent.parent_jti) throw new NativeCredentialRejected(); - +): Promise { const startedAt = Date.now(); - const response = await request(`${config.issuer}/token`, { - method: "POST", - headers: { - Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", - subject_token: token, - subject_token_type: ACCESS_TOKEN_URN, - requested_token_type: ACCESS_TOKEN_URN, - scope: parent.scope, - }), - cache: "no-store", - redirect: "error", - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - if (response.status >= 500) - throw new Error("native OAuth authority unavailable"); - const text = await response.text(); - if (text.length > 16384) throw new Error("invalid native OAuth response"); - const body: unknown = JSON.parse(text); - if (!response.ok) { - const failure = z.object({ error: z.string() }).safeParse(body); - if (response.status === 400 && failure.data?.error === "invalid_grant") - throw new NativeCredentialRejected(); - if (response.status === 400 && failure.data?.error === "invalid_scope") - throw new NativeScopeRejected(); - throw new Error("native OAuth exchange failed"); + let response: Response; + try { + response = await fetch(`${config.issuer}/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: token, + subject_token_type: ACCESS_TOKEN_URN, + requested_token_type: ACCESS_TOKEN_URN, + resource: config.apiAudience, + scope: parent.scope, + }), + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS), + }); + } catch (error) { + throw unavailable( + `native exchange failed: ${error instanceof Error ? error.message : error}`, + ); } + const body = await readExchangeResponse(response); + if (!response.ok) throw exchangeFailure(response.status, body); - const exchanged = exchangeSchema.parse(body); - const derived = await verifyAccessToken( - exchanged.access_token, - config.apiAudience, - EXCHANGE_TOKEN_LIFETIME_SECONDS, - config, - ); + const exchanged = exchangeSchema.safeParse(body); + if (!exchanged.success) + throw misconfigured("native exchange response is not RFC 8693"); + const { access_token: derivedToken, expires_in, scope } = exchanged.data; + let derived: AccessClaims; + try { + derived = await verifyAccessToken( + derivedToken, + config.apiAudience, + EXCHANGE_TOKEN_LIFETIME_SECONDS, + config, + ); + } catch (error) { + if ( + error instanceof NativeOAuthError && + error.failure.status === "unavailable" + ) + throw error; + throw misconfigured( + `exchanged token rejected: ${error instanceof Error ? error.message : error}`, + ); + } const parentScopes = parent.scope.split(" "); - if ( - derived.sub !== parent.sub || - derived.grant_id !== parent.grant_id || - derived.org_id !== parent.org_id || - (derived.project_id ?? "") !== (parent.project_id ?? "") || - derived.client_id !== config.clientId || - derived.act?.sub !== `oauth_client:${config.clientId}` || - derived.parent_jti !== parent.jti || - derived.exp > parent.exp || - derived.scope !== exchanged.scope || - exchanged.scope.split(" ").some((scope) => !parentScopes.includes(scope)) - ) - throw new Error("invalid native exchange"); + const lineage = { + sub: derived.sub === parent.sub, + grant_id: derived.grant_id === parent.grant_id, + org_id: derived.org_id === parent.org_id, + project_id: (derived.project_id ?? "") === (parent.project_id ?? ""), + client_id: derived.client_id === config.clientId, + act: derived.act?.sub === `oauth_client:${config.clientId}`, + parent_jti: derived.parent_jti === parent.jti, + exp: derived.exp <= parent.exp, + scope: + derived.scope === scope && + scope.split(" ").every((item) => parentScopes.includes(item)), + }; + const failed = Object.entries(lineage) + .filter(([, ok]) => !ok) + .map(([check]) => check); + if (failed.length > 0) + throw misconfigured( + `exchanged token lineage mismatch: ${failed.join(",")}`, + ); // expires_in and exp - iat are relative, so issuer clock skew cannot - // extend the lease past the token's real remaining lifetime. - const lifetimeMs = - Math.min(exchanged.expires_in, derived.exp - derived.iat) * 1000; - if (lifetimeMs <= 0) throw new NativeCredentialRejected(); + // stretch the local lifetime past the token's real one. + const expiresAt = + startedAt + Math.min(expires_in, derived.exp - derived.iat) * 1000; + if (expiresAt <= Date.now()) + throw rejected("native exchange issued an expired token"); return { - token: exchanged.access_token, + token: derivedToken, subject: parent.sub, - scopes: exchanged.scope.split(" "), - deadline: startedAt + Math.min(lifetimeMs, NATIVE_STREAM_LEASE_MS), + grantId: parent.grant_id, + scopes: scope.split(" "), + expiresAt, + reuseUntil: + Math.min(expiresAt, parent.exp * 1000) - DERIVED_TOKEN_MIN_REMAINING_MS, }; } -// Ends a native response when its authorization lease expires; reconnecting -// requires a fresh exchange. -export function leaseNativeResponse( - response: Response, - deadline: number, - abort: AbortController, -): Response { - if (!response.body) { - abort.abort(); - return response; +// Exchanged tokens keyed by a SHA-256 fingerprint of the incoming token, so +// neither bearer ever becomes a key. Losing an entry only costs an exchange; +// the API still checks the exchanged token's status on every request. +const derivedTokens = new Map(); +const pendingExchanges = new Map>(); + +function rememberDerivedToken(key: string, credential: CachedCredential) { + const now = Date.now(); + if (credential.reuseUntil <= now) return; + for (const [entryKey, entry] of derivedTokens) + if (entry.reuseUntil <= now) derivedTokens.delete(entryKey); + if (derivedTokens.size >= MAX_DERIVED_TOKENS) { + const oldest = derivedTokens.keys().next().value; + if (oldest) derivedTokens.delete(oldest); } - const reader = response.body.getReader(); - let finished = false; - let timer: ReturnType; - const stream = new ReadableStream({ - start(controller) { - timer = setTimeout( - () => { - if (finished) return; - finished = true; - abort.abort(); - void reader.cancel().catch(() => {}); - controller.close(); - }, - Math.max(0, deadline - Date.now()), - ); - }, - async pull(controller) { - try { - const result = await reader.read(); - if (finished) return; - if (result.done) { - finished = true; - clearTimeout(timer); - abort.abort(); - controller.close(); - } else controller.enqueue(result.value); - } catch (error) { - if (finished) return; - finished = true; - clearTimeout(timer); - abort.abort(); - controller.error(error); - } - }, - async cancel(reason) { - finished = true; - clearTimeout(timer); - abort.abort(); - await reader.cancel(reason); - }, - }); - return new Response(stream, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); + derivedTokens.set(key, credential); +} + +async function derivedCredential( + token: string, + parent: AccessClaims, + config: NativeOAuthConfig, +): Promise { + const key = createHash("sha256") + .update([config.issuer, config.audience, token].join("\0")) + .digest("hex"); + const cached = derivedTokens.get(key); + if (cached && cached.reuseUntil > Date.now()) return cached; + if (cached) derivedTokens.delete(key); + + let pending = pendingExchanges.get(key); + if (!pending) { + pending = exchange(token, parent, config) + .then((credential) => { + rememberDerivedToken(key, credential); + return credential; + }) + .finally(() => pendingExchanges.delete(key)); + pendingExchanges.set(key, pending); + } + return pending; +} + +// Authenticates a native bearer on every request: the incoming token is +// verified locally before any cached exchange is used, and its exchanged +// API-audience token is what the request carries onward. +export async function authenticateNativeOAuth( + token: string, + request: Request, +): Promise { + try { + if (token.length > MAX_TOKEN_BYTES) + throw rejected("native access token too large"); + const config = nativeOAuthConfig(request); + if (!config) throw rejected("no native issuer is trusted for this origin"); + const parent = await verifyAccessToken( + token, + config.audience, + ACCESS_TOKEN_LIFETIME_SECONDS, + config, + ); + // Exchanged tokens cannot be exchanged again. + if (parent.act || parent.parent_jti) + throw rejected("native access token is already exchanged"); + return { + status: "ok", + credential: await derivedCredential(token, parent, config), + }; + } catch (error) { + if (!(error instanceof NativeOAuthError)) throw error; + if (error.failure.status === "misconfigured") console.error(error.message); + else console.warn(error.message); + return error.failure; + } +} + +export function clearNativeOAuthCachesForTests() { + keySets.clear(); + derivedTokens.clear(); + pendingExchanges.clear(); } diff --git a/src/lib/oauth-discovery-route.test.ts b/src/lib/oauth-discovery-route.test.ts index 8e7d23d9..dfa40bd6 100644 --- a/src/lib/oauth-discovery-route.test.ts +++ b/src/lib/oauth-discovery-route.test.ts @@ -32,6 +32,8 @@ describe("/.well-known/oauth-protected-resource/mcp", () => { token_endpoint: `${issuer}/token`, registration_endpoint: `${issuer}/register`, scopes_supported: ["openid"], + resource_name: "Kernel", + resource_documentation: "https://www.kernel.sh/docs/reference/mcp-server", }); }); diff --git a/src/lib/oauth-discovery.test.ts b/src/lib/oauth-discovery.test.ts index 4e7dd9ae..c851a6e2 100644 --- a/src/lib/oauth-discovery.test.ts +++ b/src/lib/oauth-discovery.test.ts @@ -56,6 +56,8 @@ describe("OAuth protected-resource discovery", () => { token_endpoint: "https://auth.onkernel.com/token", registration_endpoint: "https://auth.onkernel.com/register", scopes_supported: ["openid"], + resource_name: "Kernel", + resource_documentation: "https://www.kernel.sh/docs/reference/mcp-server", }); }); diff --git a/src/lib/oauth-discovery.ts b/src/lib/oauth-discovery.ts index eb03315b..f4d01139 100644 --- a/src/lib/oauth-discovery.ts +++ b/src/lib/oauth-discovery.ts @@ -1,7 +1,24 @@ +import { documentation_url } from "../../server.json"; + const MCP_ORIGIN = "https://mcp.onkernel.com"; -const OAUTH_ORIGIN = "https://auth.onkernel.com"; const DEV_MCP_ORIGIN = "https://mcp.dev.onkernel.com"; -const DEV_OAUTH_ORIGIN = "https://auth.dev.onkernel.com"; + +// Each public MCP origin trusts exactly one Kernel authorization server and +// forwards to exactly one Kernel API. Other origins (local, preview) keep +// their own origin as authorization server. +const KERNEL_DEPLOYMENTS: Record< + string, + { authorizationServer: string; api: string } +> = { + [MCP_ORIGIN]: { + authorizationServer: "https://auth.onkernel.com", + api: "https://api.onkernel.com", + }, + [DEV_MCP_ORIGIN]: { + authorizationServer: "https://auth.dev.onkernel.com", + api: "https://api.dev.onkernel.com", + }, +}; export const OAUTH_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; @@ -18,6 +35,12 @@ export function oauthRequestOrigin(request: Request): string { return url.origin; } +export function kernelDeployment( + origin: string, +): { authorizationServer: string; api: string } | undefined { + return KERNEL_DEPLOYMENTS[origin]; +} + export function oauthResourceMetadataUrl(request: Request): string { return `${oauthRequestOrigin(request)}${OAUTH_RESOURCE_METADATA_PATH}`; } @@ -26,9 +49,8 @@ export function oauthResourceMetadata( request: Request, ): Record { const origin = oauthRequestOrigin(request); - let authorizationServer = origin; - if (origin === MCP_ORIGIN) authorizationServer = OAUTH_ORIGIN; - if (origin === DEV_MCP_ORIGIN) authorizationServer = DEV_OAUTH_ORIGIN; + const authorizationServer = + kernelDeployment(origin)?.authorizationServer ?? origin; return { resource: `${origin}/mcp`, @@ -37,5 +59,7 @@ export function oauthResourceMetadata( token_endpoint: `${authorizationServer}/token`, registration_endpoint: `${authorizationServer}/register`, scopes_supported: ["openid"], + resource_name: "Kernel", + resource_documentation: documentation_url, }; } From 461fa49c816ddb02f2b5a06bc1b91c3c0e1d584f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:04:19 +0000 Subject: [PATCH 08/12] Cap native credential lifetime at the exchanged token's signed expiry --- src/app/[transport]/route.test.ts | 16 +++++++++++----- src/lib/native-oauth.test.ts | 8 ++++++++ src/lib/native-oauth.ts | 13 +++++++------ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 2f961204..8f62ae44 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -384,18 +384,24 @@ describe("native OAuth credentials", () => { }); describe("end with the exchanged token", () => { - const shortLived = () => - issuer.parent({ exp: Math.floor(Date.now() / 1000) + 2 }); + let signedExpiry: number; + const shortLived = () => { + const exp = Math.floor(Date.now() / 1000) + 2; + signedExpiry = exp * 1000; + return issuer.parent({ exp }); + }; + // The stream stays open until, and not past, the exchanged token's + // signed expiry (bounded by the incoming token's exp). async function closesAt(response: Response, started: number) { expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toContain( "text/event-stream", ); await response.text(); - const elapsed = Date.now() - started; - expect(elapsed).toBeGreaterThanOrEqual(1_000); - expect(elapsed).toBeLessThan(2_500); + expect(Date.now() - started).toBeGreaterThanOrEqual(900); + expect(Date.now()).toBeGreaterThanOrEqual(signedExpiry - 100); + expect(Date.now()).toBeLessThanOrEqual(signedExpiry + 250); } test("a legacy response stream", async () => { diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 37a521bb..1fa2bbd8 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -179,6 +179,14 @@ describe("native token exchange", () => { ); }); + test("caps the lifetime at the signed expiry when the issuer clock is behind", async () => { + const behind = Math.floor(Date.now() / 1000) - 20; + issuer.deriveWith({ iat: behind, exp: behind + 300 }); + const result = await authenticate(await issuer.parent()); + if (result.status !== "ok") throw new Error(result.status); + expect(result.credential.expiresAt).toBe((behind + 300) * 1000); + }); + test("rejects tokens outside the issuer profile before any exchange", async () => { const now = Math.floor(Date.now() / 1000); const other = await generateKeyPair("RS256"); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index b30b119a..834dfd5b 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -343,10 +343,12 @@ async function exchange( `exchanged token lineage mismatch: ${failed.join(",")}`, ); - // expires_in and exp - iat are relative, so issuer clock skew cannot - // stretch the local lifetime past the token's real one. - const expiresAt = - startedAt + Math.min(expires_in, derived.exp - derived.iat) * 1000; + // The relative lifetime covers an issuer clock running ahead, the signed + // exp one running behind; lineage already bounds exp by the parent's. + const expiresAt = Math.min( + startedAt + Math.min(expires_in, derived.exp - derived.iat) * 1000, + derived.exp * 1000, + ); if (expiresAt <= Date.now()) throw rejected("native exchange issued an expired token"); return { @@ -355,8 +357,7 @@ async function exchange( grantId: parent.grant_id, scopes: scope.split(" "), expiresAt, - reuseUntil: - Math.min(expiresAt, parent.exp * 1000) - DERIVED_TOKEN_MIN_REMAINING_MS, + reuseUntil: expiresAt - DERIVED_TOKEN_MIN_REMAINING_MS, }; } From 40772aec47e4e41d33ab728499e20c74d865c428 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:53:35 +0000 Subject: [PATCH 09/12] Resolve native OAuth at startup and cache exchanged tokens in Redis 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. --- .env.example | 19 +- docs/native-oauth.md | 121 +++---- src/app/[transport]/route.test.ts | 35 +-- src/app/[transport]/route.ts | 8 +- src/instrumentation.ts | 13 + src/lib/mcp/tools/auth-login-app.ts | 11 +- src/lib/native-oauth.test-fixtures.ts | 20 ++ src/lib/native-oauth.test.ts | 241 ++++++++++---- src/lib/native-oauth.ts | 436 +++++++++++++++++--------- src/lib/oauth-discovery-route.test.ts | 4 +- src/lib/oauth-discovery.test.ts | 4 +- src/lib/oauth-discovery.ts | 12 +- src/lib/redis.ts | 29 ++ 13 files changed, 604 insertions(+), 349 deletions(-) create mode 100644 src/instrumentation.ts diff --git a/.env.example b/.env.example index bdb7dd18..c48fc904 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,12 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ -# Kernel-issued (native) OAuth token exchange client. Required for native -# OAuth tokens; without it they fail with a server error while Clerk tokens and -# API keys keep working. See docs/native-oauth.md. -NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID= -NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET= -# Only for origins other than mcp.onkernel.com / mcp.dev.onkernel.com, such as -# local development against a local API: http://localhost:3001 -# NATIVE_OAUTH_ISSUER= +# Kernel-issued (native) OAuth; see docs/native-oauth.md. Required on +# mcp.onkernel.com and mcp.dev.onkernel.com. Elsewhere set all three or none +# (none turns native OAuth off). +# NATIVE_OAUTH_ISSUER=http://localhost:3001 +# NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID= +# NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET= # OAuth Configuration NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev @@ -23,8 +21,9 @@ NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev API_BASE_URL= # Public origin of this MCP server. The Managed Auth MCP App connects only to -# the narrowly scoped same-origin relay. Local development: http://localhost:3002 -MANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com +# the narrowly scoped same-origin relay, and native OAuth trust follows it. +# Defaults to https://mcp.onkernel.com. +MANAGED_AUTH_APP_ORIGIN=http://localhost:3002 # Mintlify API Configuration - Only needed for the search_docs tool call MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_ diff --git a/docs/native-oauth.md b/docs/native-oauth.md index 9bd073b5..5fe416b5 100644 --- a/docs/native-oauth.md +++ b/docs/native-oauth.md @@ -21,8 +21,9 @@ On every request the server: the trusted issuer's `/.well-known/jwks.json`, never from a location named in the token. The key set is cached for ten minutes and refetched at most every 30 s when an unknown key id appears. -2. Exchanges it at `/token` (RFC 8693, `client_secret_basic`) for an - API-audience token with `resource=` and the same scope. +2. Reuses a cached exchanged token for this incoming token, or exchanges it + at `/token` (RFC 8693, `client_secret_basic`) for an API-audience + token with `resource=` and the same scope. 3. Verifies the exchanged token and its lineage: API audience, at most five minutes, same `sub`/`grant_id`/`org_id`/`project_id`, `client_id` and `act.sub` naming the exchange client, `parent_jti` equal to the incoming @@ -30,25 +31,27 @@ On every request the server: 4. Calls the Kernel API, starting with `GET /auth/context`, with only the exchanged token. The MCP-audience token never reaches the API. -| Outcome | Response | -| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| token fails local verification | 401 `invalid_token` | -| no native issuer trusted for this origin | 401 `invalid_token` | -| issuer answers `invalid_grant` (revoked, expired) | 401 `invalid_token` | -| issuer answers `invalid_scope` | 403 `insufficient_scope` | -| issuer keys or exchange unreachable, 5xx | 503 `temporarily_unavailable` | -| missing exchange credentials, `invalid_target`, `invalid_client`, other OAuth errors, exchanged token outside its lineage | 500 `server_error`, logged | +| Outcome | Response | +| ------------------------------------------------------------------------------------------------- | ----------------------------- | +| native OAuth not configured on this server | 401 `invalid_token` | +| token fails local verification | 401 `invalid_token` | +| issuer answers `invalid_grant` (revoked, expired) | 401 `invalid_token` | +| issuer answers `invalid_scope` | 403 `insufficient_scope` | +| issuer keys or token endpoint unreachable, 5xx, `temporarily_unavailable` | 503 `temporarily_unavailable` | +| issuer refuses this server (`invalid_target`, `invalid_client`, …) or returns an unexpected token | 503, logged as an error | ### Caching and freshness -- **Exchanged tokens** are cached in memory, keyed by a SHA-256 fingerprint of - the incoming token (never the token itself), at most 1,000 entries. An entry - is reused only while at least a minute of its lifetime remains, bounded by - both tokens' expiry. Concurrent requests with the same token share one - exchange. The incoming token is verified before the cache is consulted, and - the API checks the exchanged token's status on every call, so revocation - still takes effect on the next request. Losing the cache costs only a new - exchange. +- **Exchanged tokens** are cached in Redis so every instance can reuse them. + The key is an HMAC of the issuer, MCP audience and incoming token. The value + is encrypted with AES-256-GCM under a key derived (HKDF-SHA256) from the + incoming token, so the cache contents are useless without the incoming + bearer. An entry expires a minute before the exchanged token does, and a + cached token is used only after it decrypts and verifies again against the + incoming token. A miss, a Redis error or a read slower than 500 ms falls back + to a fresh exchange. Concurrent requests on one instance share an exchange. +- **Revocation** is unaffected: the incoming token is verified on every + request, and the API checks the exchanged token's status on every call. - **`/auth/context`** is resolved on every native request; nothing derived from it is reused across requests. - **Identity** for MCP Apps markers, analytics sessions and the entitlements @@ -61,71 +64,29 @@ On every request the server: ## Configuration -Trust follows the public MCP origin, the same mapping discovery advertises: +Native configuration is resolved once at startup from this server's public +origin, `MANAGED_AUTH_APP_ORIGIN` (default `https://mcp.onkernel.com`). | MCP origin | Issuer | API | | ------------------------------ | ------------------------------- | ------------------------------ | | `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` | -| Variable | Required | Purpose | -| ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | -| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` | yes | the token-exchange client configured on the authorization server | -| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET` | yes (secret) | its secret | -| `API_BASE_URL` | yes | must be the API origin for this MCP origin; exchanged tokens are valid only there | -| `NATIVE_OAUTH_ISSUER` | only for other origins | issuer for origins outside the table, such as `http://localhost:3002`; ignored for the two above | - -If the exchange credentials are missing or `API_BASE_URL` does not match, -native tokens fail with a 500 and a log line naming the setting; Clerk tokens -and API keys are unaffected. Configuration is read per request, so a build or -an unrelated route never fails on it. On origins outside the table without -`NATIVE_OAUTH_ISSUER`, native tokens are rejected with 401. - -### Provisioning the exchange secret - -The MCP server's runtime configuration lives in its Vercel project's -environment variables, one set per environment (staging serves -`mcp.dev.onkernel.com`, production `mcp.onkernel.com`). For each environment: - -1. Take the exchange client ID and raw secret that the authorization server - for that environment is configured with. Never reuse a secret across - environments or commit it. -2. Add `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` and - `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET` (marked sensitive) to that - environment only, and confirm `API_BASE_URL` is the matching API origin. -3. Redeploy that environment so the new variables take effect. - -## Activation prerequisites - -1. The authorization server accepts the path-bearing MCP resource - (`/mcp`) as the MCP audience, validates the exchange `resource` - (`invalid_target` otherwise), and has the static exchange client configured. - That server change must be deployed before this one is exercised. -2. The exchange client ID and secret are provisioned in the MCP environment as - above. -3. In staging, route new OAuth flows natively on the authorization server - (`NATIVE_OAUTH_DEFAULT_NEW_FLOWS=true`) and run the acceptance checks - below. Production follows only after staging passes and is approved. - -Exchanged tokens reach only the API operations available to scoped OAuth -tokens; other tools answer 403 until the API allows them. - -## Staging acceptance - -1. Fetch `https://mcp.dev.onkernel.com/.well-known/oauth-protected-resource/mcp`: - `resource` is `https://mcp.dev.onkernel.com/mcp`, `authorization_servers` - is `https://auth.dev.onkernel.com`, and `resource_name` / - `resource_documentation` are present. -2. Connect an MCP client from scratch: dynamic registration, browser PKCE - login and consent. The access token has `typ: at+jwt` and - `aud: ["https://mcp.dev.onkernel.com/mcp"]`. -3. `initialize`, `tools/list` and a project-scoped tool call (for example a - browser list) succeed; a different project's resource is denied. -4. Repeated requests with the same access token perform one exchange, and a - refreshed token performs a new one. -5. On an MCP Apps host, the managed-auth login app opens and its app-only - tools run after a token refresh. -6. Hold a stream open: it closes when the exchanged token expires and the - client reconnects. -7. Revoke the grant: the next request is 401 and the client re-authorizes. -8. Clerk OAuth and API-key connections behave as before. +| Variable | Purpose | +| ----------------------------------------- | ------------------------------------------------------------------------- | +| `MANAGED_AUTH_APP_ORIGIN` | this server's public origin; selects the row above | +| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID` | the token-exchange client configured on the authorization server | +| `NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET` | its secret | +| `API_BASE_URL` | the API origin; exchanged tokens are only valid at their own API | +| `NATIVE_OAUTH_ISSUER` | issuer for any other origin, such as local development; HTTPS or loopback | + +- **The two origins above** always accept native tokens. Both exchange + variables are required, `API_BASE_URL` must be that row's API, and + `NATIVE_OAUTH_ISSUER` must not be set. +- **Any other origin** sets all of `NATIVE_OAUTH_ISSUER` and both exchange + variables, or none of them. With none, native OAuth is off and native tokens + are rejected like any untrusted token. The API is `API_BASE_URL`. + +Missing or inconsistent settings stop the server at startup: Next.js runs the +check in `src/instrumentation.ts` before serving, and an instance whose check +fails serves no requests. `next build` does not run the check. diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 8f62ae44..0baa7a89 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -8,8 +8,11 @@ import { test, } from "bun:test"; import { decodeJwt } from "jose"; -import { clearNativeOAuthCachesForTests } from "@/lib/native-oauth"; -import { nativeIssuer } from "@/lib/native-oauth.test-fixtures"; +import { resetNativeOAuthForTests } from "@/lib/native-oauth"; +import { + memoryExchangeStore, + nativeIssuer, +} from "@/lib/native-oauth.test-fixtures"; import { Kernel } from "@onkernel/sdk"; import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics"; import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; @@ -144,9 +147,9 @@ describe("native OAuth credentials", () => { let fetchSpy: ReturnType>; beforeEach(async () => { - clearNativeOAuthCachesForTests(); issuer = await nativeIssuer(); Object.assign(process.env, issuer.env()); + resetNativeOAuthForTests(memoryExchangeStore().store); kernelTokens = []; kernelPaths = []; browsersRequested = () => {}; @@ -198,6 +201,7 @@ describe("native OAuth credentials", () => { for (const key of envKeys) if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; + resetNativeOAuthForTests(); }); function mcpRequest( @@ -296,10 +300,14 @@ describe("native OAuth credentials", () => { ); }); - test("are rejected without contacting the issuer or API on an untrusted origin", async () => { - delete process.env.NATIVE_OAUTH_ISSUER; + test("are rejected like any untrusted token when native OAuth is off", async () => { + for (const key of envKeys) + if (key !== "API_BASE_URL") delete process.env[key]; + process.env.MANAGED_AUTH_APP_ORIGIN = "http://localhost:3002"; + resetNativeOAuthForTests(memoryExchangeStore().store); const response = await POST(mcpRequest(await issuer.parent())); expect(response.status).toBe(401); + expect((await response.json()).error).toBe("invalid_token"); expect(response.headers.get("WWW-Authenticate")).toContain( "resource_metadata=", ); @@ -359,23 +367,6 @@ describe("native OAuth credentials", () => { expect(kernelTokens).toEqual([]); }); - test("a missing exchange secret is a server error, never a Clerk fallback", async () => { - const errors = spyOn(console, "error").mockImplementation(() => {}); - try { - delete process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET; - const response = await POST(mcpRequest(await issuer.parent())); - expect(response.status).toBe(500); - expect((await response.json()).error).toBe("server_error"); - expect(String(errors.mock.calls[0]?.[0])).toContain( - "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", - ); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(kernelTokens).toEqual([]); - } finally { - errors.mockRestore(); - } - }); - test("post-authentication bugs propagate as server errors", async () => { failingAuthContext(new Error("unaccountable")); await expect(POST(mcpRequest(await issuer.parent()))).rejects.toThrow( diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 60b5a698..bd89f283 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -291,13 +291,7 @@ async function handleAuthenticatedRequest( } if (isNativeOAuthCredential(token)) { - const native = await authenticateNativeOAuth(token, req); - if (native.status === "misconfigured") - return errorResponse( - 500, - "server_error", - "Kernel OAuth is not configured on this server", - ); + const native = await authenticateNativeOAuth(token); if (native.status !== "ok") return connectionScopeFailureResponse(req, native); const { credential } = native; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..f9e00955 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,13 @@ +// Next.js runs register() once per server instance before it serves +// requests. A throw here keeps the instance from serving anything, so invalid +// native OAuth settings stop a deployment instead of failing per request. +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + const { nativeOAuthConfig } = await import("@/lib/native-oauth"); + const config = nativeOAuthConfig(); + console.info( + config + ? `native OAuth enabled: issuer ${config.issuer}, resource ${config.audience}` + : "native OAuth disabled", + ); +} diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 001121cb..09f4b3f4 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; import { mcpAppsAuthSubject } from "@/lib/mcp-apps-marker"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { mcpServerOrigin } from "@/lib/oauth-discovery"; import { initializeDeclaresMcpApps, mcpAppsGateError, @@ -41,18 +42,12 @@ export const MANAGED_AUTH_RESOURCE_URI = "ui://kernel/managed-auth-login-v10.html"; export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; -export function managedAuthAppOrigin(): string { - const configured = - process.env.MANAGED_AUTH_APP_ORIGIN ?? "https://mcp.onkernel.com"; - return new URL(configured).origin; -} - export function managedAuthResourceMeta() { return { ui: { prefersBorder: true, csp: { - connectDomains: [managedAuthAppOrigin()], + connectDomains: [mcpServerOrigin()], }, }, }; @@ -325,7 +320,7 @@ export function registerAuthLoginApp(server: McpServer) { handoff_code: result.handoff_code, }), ...(result.hosted_url && { hosted_url: result.hosted_url }), - relay_base_url: `${managedAuthAppOrigin()}/managed-auth-proxy`, + relay_base_url: `${mcpServerOrigin()}/managed-auth-proxy`, }; return { content: [ diff --git a/src/lib/native-oauth.test-fixtures.ts b/src/lib/native-oauth.test-fixtures.ts index 206d5280..824db154 100644 --- a/src/lib/native-oauth.test-fixtures.ts +++ b/src/lib/native-oauth.test-fixtures.ts @@ -147,6 +147,7 @@ export async function nativeIssuer({ revoked, // Server configuration for an origin outside the known deployments. env: () => ({ + MANAGED_AUTH_APP_ORIGIN: config.origin, NATIVE_OAUTH_ISSUER: config.issuer, API_BASE_URL: config.apiAudience, NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID: config.clientId, @@ -160,3 +161,22 @@ export async function nativeIssuer({ }, }; } + +// The exchanged-token cache as Redis holds it: values with a PX expiry. +// Several instances share one store; `fail` makes every call throw. +export function memoryExchangeStore() { + const entries = new Map(); + const state = { fail: false }; + const store = { + async get(cacheIdentity: string) { + if (state.fail) throw new Error("redis down"); + const entry = entries.get(cacheIdentity); + return entry && entry.expiresAt > Date.now() ? entry.value : null; + }, + async set(cacheIdentity: string, value: string, ttlMs: number) { + if (state.fail) throw new Error("redis down"); + entries.set(cacheIdentity, { value, expiresAt: Date.now() + ttlMs }); + }, + }; + return { store, entries, state }; +} diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 1fa2bbd8..4c70692b 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -10,12 +10,17 @@ import { import { decodeJwt, generateKeyPair, SignJWT } from "jose"; import { authenticateNativeOAuth, - clearNativeOAuthCachesForTests, isNativeOAuthCredential, + nativeOAuthConfig, + resetNativeOAuthForTests, } from "./native-oauth"; -import { nativeIssuer } from "./native-oauth.test-fixtures"; +import { + memoryExchangeStore, + nativeIssuer, +} from "./native-oauth.test-fixtures"; const ENV_KEYS = [ + "MANAGED_AUTH_APP_ORIGIN", "API_BASE_URL", "NATIVE_OAUTH_ISSUER", "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID", @@ -26,22 +31,27 @@ const savedEnv = Object.fromEntries( ); let issuer: Awaited>; +let cache: ReturnType; let fetchSpy: ReturnType>; -function useIssuer(next: typeof issuer) { - issuer = next; +// A fresh server instance: configuration, keys and in-flight exchanges are +// per instance, while the exchanged-token cache is shared. +function newInstance() { + resetNativeOAuthForTests(cache.store); +} + +function useEnv(env: Record) { for (const key of ENV_KEYS) delete process.env[key]; - Object.assign(process.env, issuer.env()); + Object.assign(process.env, env); + newInstance(); } -const mcpRequest = (origin = issuer.config.origin) => - new Request(`${origin}/mcp`, { method: "POST" }); -const authenticate = async (token: string, request = mcpRequest()) => - authenticateNativeOAuth(token, request); +const authenticate = (token: string) => authenticateNativeOAuth(token); beforeEach(async () => { - clearNativeOAuthCachesForTests(); - useIssuer(await nativeIssuer()); + cache = memoryExchangeStore(); + issuer = await nativeIssuer(); + useEnv(issuer.env()); fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async ( input: string | URL | Request, init?: RequestInit, @@ -54,6 +64,7 @@ afterEach(() => { for (const key of ENV_KEYS) if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; + resetNativeOAuthForTests(); }); describe("native credential detection", () => { @@ -111,22 +122,23 @@ describe("native token exchange", () => { }); }); - test("reuses one exchange per incoming token until a minute before expiry", async () => { + test("reuses an exchange across instances until a minute before expiry", async () => { const now = Date.now(); setSystemTime(now); const parent = await issuer.parent(); const first = await authenticate(parent); - const second = await authenticate(parent); expect(first.status).toBe("ok"); - expect(second).toEqual(first); + newInstance(); + expect(await authenticate(parent)).toEqual(first); expect(issuer.requests).toHaveLength(1); - // A different incoming token is a different cache entry. + // A refreshed incoming token is a different cache entry. await authenticate(await issuer.parent()); expect(issuer.requests).toHaveLength(2); // The 300s exchanged token is reused for 240s, then replaced. setSystemTime(now + 239_000); + newInstance(); expect(await authenticate(parent)).toEqual(first); setSystemTime(now + 241_000); const renewed = await authenticate(parent); @@ -134,11 +146,62 @@ describe("native token exchange", () => { throw new Error("not ok"); expect(renewed.credential.token).not.toBe(first.credential.token); expect(issuer.requests).toHaveLength(3); - // Local verification still runs on every request; the JWKS is cached. - expect(issuer.jwksRequests).toHaveLength(1); }); - test("coalesces concurrent exchanges of the same token", async () => { + test("stores only ciphertext that the incoming token alone can open", async () => { + const parent = await issuer.parent(); + const result = await authenticate(parent); + if (result.status !== "ok") throw new Error(result.status); + const [[identity, { value }]] = [...cache.entries]; + expect(value).not.toContain(result.credential.token); + expect(value).not.toContain(result.credential.token.split(".")[1]); + expect(value).not.toContain(parent.split(".")[2]); + + // Another token's identity cannot reuse this ciphertext, and a corrupted + // entry is discarded: both fall back to a fresh exchange. + const other = await issuer.parent(); + const otherIdentity = identity!.replace(parent, other); + cache.entries.set(otherIdentity, { value, expiresAt: Date.now() + 60_000 }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const fresh = await authenticate(other); + if (fresh.status !== "ok") throw new Error(fresh.status); + expect(fresh.credential.token).not.toBe(result.credential.token); + const corrupted = `${value.slice(0, -4)}AAAA`; + cache.entries.set(identity!, { + value: corrupted, + expiresAt: Date.now() + 60_000, + }); + newInstance(); + expect((await authenticate(parent)).status).toBe("ok"); + expect(issuer.requests).toHaveLength(3); + expect(String(warn.mock.calls.at(-1)?.[0])).toContain("discarded"); + } finally { + warn.mockRestore(); + } + }); + + test("falls back to a fresh exchange when the cache fails or stalls", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const parent = await issuer.parent(); + cache.state.fail = true; + expect((await authenticate(parent)).status).toBe("ok"); + expect((await authenticate(parent)).status).toBe("ok"); + expect(issuer.requests).toHaveLength(2); + + cache.state.fail = false; + cache.store.get = () => new Promise(() => {}); + const started = Date.now(); + expect((await authenticate(parent)).status).toBe("ok"); + expect(Date.now() - started).toBeLessThan(2_000); + expect(issuer.requests).toHaveLength(3); + } finally { + warn.mockRestore(); + } + }); + + test("coalesces concurrent exchanges of the same token on one instance", async () => { const parent = await issuer.parent(); const results = await Promise.all( Array.from({ length: 5 }, () => authenticate(parent)), @@ -158,6 +221,7 @@ describe("native token exchange", () => { if (first.status !== "ok") throw new Error(first.status); expect(first.credential.expiresAt).toBeGreaterThanOrEqual(started + 19_000); expect(first.credential.expiresAt).toBeLessThanOrEqual(Date.now() + 20_000); + expect(cache.entries.size).toBe(0); await authenticate(parent); expect(issuer.requests).toHaveLength(2); }); @@ -249,16 +313,16 @@ describe("native token exchange", () => { ], [ async () => Response.json({ error: "invalid_target" }, { status: 400 }), - { status: "misconfigured" }, + { status: "unavailable" }, ], [ async () => Response.json({ error: "unauthorized_client" }, { status: 400 }), - { status: "misconfigured" }, + { status: "unavailable" }, ], [ async () => Response.json({ error: "invalid_client" }, { status: 401 }), - { status: "misconfigured" }, + { status: "unavailable" }, ], [ async () => @@ -299,9 +363,9 @@ describe("native token exchange", () => { }); test("an API audience the issuer does not serve is invalid_target", async () => { - process.env.API_BASE_URL = "https://api.other.test"; + useEnv({ ...issuer.env(), API_BASE_URL: "https://api.other.test" }); expect(await authenticate(await issuer.parent())).toEqual({ - status: "misconfigured", + status: "unavailable", }); expect( new URLSearchParams(String(issuer.requests[0]!.init.body)).get( @@ -347,7 +411,7 @@ describe("native token exchange", () => { issuer.respondWith(response); errors.mockClear(); expect(await authenticate(await issuer.parent())).toEqual({ - status: "misconfigured", + status: "unavailable", }); expect(String(errors.mock.calls[0]?.[0])).toContain(check); } @@ -358,31 +422,67 @@ describe("native token exchange", () => { }); describe("native configuration", () => { - test("derives issuer, audiences and API from the public MCP origin", async () => { - useIssuer( - await nativeIssuer({ - issuer: "https://auth.dev.onkernel.com", - origin: "https://mcp.dev.onkernel.com", - apiAudience: "https://api.dev.onkernel.com", - }), - ); - // The override applies only outside the known deployments. - process.env.NATIVE_OAUTH_ISSUER = "https://auth.example.test"; - const result = await authenticate( - await issuer.parent(), - new Request("http://localhost:3002/mcp", { - headers: { Host: "mcp.dev.onkernel.com" }, - }), - ); - expect(result.status).toBe("ok"); + const dev = { + MANAGED_AUTH_APP_ORIGIN: "https://mcp.dev.onkernel.com", + API_BASE_URL: "https://api.dev.onkernel.com", + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID: "kcl_mcp", + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: "secret+/=", + }; + + test("derives issuer, audiences and API from this server's public origin", async () => { + issuer = await nativeIssuer({ + issuer: "https://auth.dev.onkernel.com", + origin: "https://mcp.dev.onkernel.com", + apiAudience: "https://api.dev.onkernel.com", + }); + useEnv(dev); + expect(nativeOAuthConfig()).toEqual({ + issuer: "https://auth.dev.onkernel.com", + audience: "https://mcp.dev.onkernel.com/mcp", + apiAudience: "https://api.dev.onkernel.com", + clientId: "kcl_mcp", + clientSecret: "secret+/=", + }); + expect((await authenticate(await issuer.parent())).status).toBe("ok"); expect(issuer.jwksRequests).toEqual([ "https://auth.dev.onkernel.com/.well-known/jwks.json", ]); expect(issuer.requests[0]!.url).toBe("https://auth.dev.onkernel.com/token"); }); - test("trusts no native issuer on other origins without an override", async () => { - delete process.env.NATIVE_OAUTH_ISSUER; + test("treats an unset public origin as production, like the managed-auth app", () => { + useEnv({ + API_BASE_URL: "https://api.onkernel.com", + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID: "kcl_mcp", + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: "secret", + }); + expect(nativeOAuthConfig()).toMatchObject({ + issuer: "https://auth.onkernel.com", + audience: "https://mcp.onkernel.com/mcp", + }); + }); + + test.each([ + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID", undefined], + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", undefined], + ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", ""], + ["API_BASE_URL", undefined], + ["API_BASE_URL", "https://api.onkernel.com"], + ["NATIVE_OAUTH_ISSUER", "https://auth.dev.onkernel.com"], + ])("a Kernel deployment refuses to start with %s=%s", (key, value) => { + const env: Record = { ...dev }; + if (value === undefined) delete env[key]; + else env[key] = value; + useEnv(env); + expect(() => nativeOAuthConfig()).toThrow(); + }); + + test("other origins without native settings reject native tokens like any untrusted token", async () => { + useEnv({ + MANAGED_AUTH_APP_ORIGIN: "http://localhost:3002", + API_BASE_URL: "https://api.dev.onkernel.com", + }); + expect(nativeOAuthConfig()).toBeNull(); expect(await authenticate(await issuer.parent())).toEqual({ status: "rejected", statusCode: 401, @@ -390,30 +490,51 @@ describe("native configuration", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + test("accepts a loopback issuer for local development", () => { + useEnv({ + ...issuer.env(), + MANAGED_AUTH_APP_ORIGIN: "http://localhost:3002", + NATIVE_OAUTH_ISSUER: "http://localhost:3001", + API_BASE_URL: "http://localhost:3001", + }); + expect(nativeOAuthConfig()).toMatchObject({ + issuer: "http://localhost:3001", + audience: "http://localhost:3002/mcp", + apiAudience: "http://localhost:3001", + }); + }); + test.each([ + ["NATIVE_OAUTH_ISSUER", undefined], ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID", undefined], ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", undefined], - ["NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", ""], ["API_BASE_URL", undefined], ["NATIVE_OAUTH_ISSUER", "http://auth.example.test"], ["NATIVE_OAUTH_ISSUER", "https://auth.example.test/"], - ])("fails closed without network when %s=%s", async (key, value) => { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - expect(await authenticate(await issuer.parent())).toEqual({ - status: "misconfigured", - }); - expect(fetchSpy).not.toHaveBeenCalled(); + ])("other origins refuse partial settings: %s=%s", (key, value) => { + const env: Record = issuer.env(); + if (value === undefined) delete env[key]; + else env[key] = value; + useEnv(env); + expect(() => nativeOAuthConfig()).toThrow(); }); - test("refuses to send a known deployment's tokens to another API", async () => { - process.env.API_BASE_URL = "https://api.onkernel.com"; - expect( - await authenticate( - await issuer.parent(), - new Request("https://mcp.dev.onkernel.com/mcp"), - ), - ).toEqual({ status: "misconfigured" }); - expect(fetchSpy).not.toHaveBeenCalled(); + test("startup fails on invalid settings and reports the native state", async () => { + const { register } = await import("@/instrumentation"); + const info = spyOn(console, "info").mockImplementation(() => {}); + process.env.NEXT_RUNTIME = "nodejs"; + try { + await register(); + expect(String(info.mock.calls[0]?.[0])).toContain( + "native OAuth enabled: issuer https://auth.example.test", + ); + useEnv({ ...dev, NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: "" }); + await expect(register()).rejects.toThrow( + "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET", + ); + } finally { + delete process.env.NEXT_RUNTIME; + info.mockRestore(); + } }); }); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 834dfd5b..1e99de00 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -1,4 +1,9 @@ -import { createHash } from "node:crypto"; +import { + createCipheriv, + createDecipheriv, + hkdfSync, + randomBytes, +} from "node:crypto"; import { createRemoteJWKSet, decodeProtectedHeader, @@ -7,7 +12,8 @@ import { type JWTVerifyGetKey, } from "jose"; import { z } from "zod"; -import { kernelDeployment, oauthRequestOrigin } from "@/lib/oauth-discovery"; +import { kernelDeployment, mcpServerOrigin } from "@/lib/oauth-discovery"; +import { getNativeExchange, setNativeExchange } from "@/lib/redis"; // The Kernel issuer's access-token profile: at most 4 KiB, MCP-audience // tokens live ten minutes, exchanged API-audience tokens five. @@ -19,11 +25,12 @@ const ACCESS_TOKEN_TYPE = "at+jwt"; const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; const JWKS_PATH = "/.well-known/jwks.json"; const EXCHANGE_TIMEOUT_MS = 5_000; +const CACHE_READ_TIMEOUT_MS = 500; +const CACHE_KEY_INFO = "kernel-mcp native exchange cache v1"; -// A derived token is reused only while at least this much of its lifetime +// An exchanged token is reused only while at least this much of its lifetime // remains, so every request gets a usable window and clock skew is covered. const DERIVED_TOKEN_MIN_REMAINING_MS = 60_000; -const MAX_DERIVED_TOKENS = 1_000; const accessClaimsSchema = z.object({ sub: z.string().min(1), @@ -50,6 +57,11 @@ const exchangeSchema = z.object({ scope: z.string().min(1), }); +const cachedExchangeSchema = z.object({ + token: z.string().min(1).max(MAX_TOKEN_BYTES), + expiresAt: z.number(), +}); + export type NativeCredential = { // The exchanged API-audience token; the only token sent to the Kernel API. token: string; @@ -63,8 +75,7 @@ export type NativeCredential = { export type NativeOAuthResult = | { status: "ok"; credential: NativeCredential } | { status: "rejected"; statusCode: 401 | 403 } - | { status: "unavailable" } - | { status: "misconfigured" }; + | { status: "unavailable" }; type NativeOAuthFailure = Exclude; @@ -72,6 +83,8 @@ class NativeOAuthError extends Error { constructor( readonly failure: NativeOAuthFailure, message: string, + // A fault on this server's or the issuer's side rather than the caller's. + readonly serverFault = false, ) { super(message); } @@ -81,8 +94,11 @@ const rejected = (message: string) => new NativeOAuthError({ status: "rejected", statusCode: 401 }, message); const unavailable = (message: string) => new NativeOAuthError({ status: "unavailable" }, message); -const misconfigured = (message: string) => - new NativeOAuthError({ status: "misconfigured" }, message); +const serverFault = (message: string) => + new NativeOAuthError({ status: "unavailable" }, message, true); + +const reason = (error: unknown) => + error instanceof Error ? error.message : String(error); // Matches the Kernel API's own selector: a compact JWT whose header typ is // at+jwt. Clerk session tokens carry typ JWT and keep their existing path. @@ -98,34 +114,15 @@ export function isNativeOAuthCredential(token: string): boolean { } } -type NativeOAuthConfig = { +export type NativeOAuthConfig = { issuer: string; audience: string; apiAudience: string; clientId: string; clientSecret: string; - keys: JWTVerifyGetKey; }; -const keySets = new Map(); - -// Keys come only from the configured issuer's JWKS, never from a URL in the -// token. jose caches the set, refetches for an unknown kid at most every 30s, -// and bounds each fetch to five seconds. -function issuerKeys(issuer: string): JWTVerifyGetKey { - let keys = keySets.get(issuer); - if (!keys) { - keys = createRemoteJWKSet(new URL(JWKS_PATH, issuer), { - timeoutDuration: 5_000, - cooldownDuration: 30_000, - cacheMaxAge: 10 * 60_000, - }); - keySets.set(issuer, keys); - } - return keys; -} - -function configuredOrigin(name: string, value: string): string { +function issuerOrigin(value: string): string { const url = URL.canParse(value) ? new URL(value) : null; const loopback = ["localhost", "127.0.0.1", "[::1]"].includes( url?.hostname ?? "", @@ -135,49 +132,79 @@ function configuredOrigin(name: string, value: string): string { url.origin !== value || (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ) - throw misconfigured(`${name} must be an HTTPS or loopback origin`); + throw new Error("NATIVE_OAUTH_ISSUER must be an HTTPS or loopback origin"); return value; } -// Trust is derived from the public MCP origin, the same mapping discovery -// advertises. NATIVE_OAUTH_ISSUER exists only for origins outside that -// mapping, such as local development. Returns null when this origin trusts -// no native issuer. -function nativeOAuthConfig(request: Request): NativeOAuthConfig | null { - const origin = oauthRequestOrigin(request); +// The public MCP origin selects the deployment. The two Kernel deployments +// always accept native tokens and need their exchange client and matching +// API; any other origin is either fully configured or has native OAuth off. +// Throws on partial or inconsistent settings. +function resolveNativeOAuthConfig(): NativeOAuthConfig | null { + const origin = mcpServerOrigin(); const deployment = kernelDeployment(origin); - const issuerOverride = process.env.NATIVE_OAUTH_ISSUER; - if (!deployment && !issuerOverride) return null; - const issuer = - deployment?.authorizationServer ?? - configuredOrigin("NATIVE_OAUTH_ISSUER", issuerOverride!); + const { + NATIVE_OAUTH_ISSUER: issuerOverride, + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID: clientId, + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: clientSecret, + API_BASE_URL: apiBaseUrl, + } = process.env; + if (deployment && issuerOverride) + throw new Error( + `NATIVE_OAUTH_ISSUER must not be set for ${origin}, whose issuer is ${deployment.authorizationServer}`, + ); + if (!deployment && !issuerOverride && !clientId && !clientSecret) return null; - // Exchanged tokens are only valid at the API they were issued for, so the - // API this server calls must be that audience. - const apiBaseUrl = process.env.API_BASE_URL; + const issuer = + deployment?.authorizationServer ?? issuerOrigin(issuerOverride!); + if (!clientId || !clientSecret) + throw new Error( + `Native OAuth for ${origin} requires NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID and NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET`, + ); + // Exchanged tokens are only valid at the API they were issued for. const apiOrigin = apiBaseUrl && URL.canParse(apiBaseUrl) ? new URL(apiBaseUrl).origin : null; const apiAudience = deployment?.api ?? apiOrigin; if (!apiAudience || apiOrigin !== apiAudience) - throw misconfigured(`API_BASE_URL must be ${apiAudience ?? "set"}`); - - const clientId = process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID; - const clientSecret = process.env.NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET; - if (!clientId || !clientSecret) - throw misconfigured( - "NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_ID and NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET are required", + throw new Error( + `Native OAuth for ${origin} requires API_BASE_URL to be ${apiAudience ?? "an API origin"}`, ); - return { issuer, audience: `${origin}/mcp`, apiAudience, clientId, clientSecret, - keys: issuerKeys(issuer), }; } +let resolvedConfig: { value: NativeOAuthConfig | null } | undefined; + +// Resolved once. Instrumentation calls this at startup, so an invalid +// configuration stops the server before it serves a request. +export function nativeOAuthConfig(): NativeOAuthConfig | null { + resolvedConfig ??= { value: resolveNativeOAuthConfig() }; + return resolvedConfig.value; +} + +const keySets = new Map(); + +// Keys come only from the configured issuer's JWKS, never from a URL in the +// token. jose caches the set, refetches for an unknown kid at most every 30s, +// and bounds each fetch to five seconds. +function issuerKeys(issuer: string): JWTVerifyGetKey { + let keys = keySets.get(issuer); + if (!keys) { + keys = createRemoteJWKSet(new URL(JWKS_PATH, issuer), { + timeoutDuration: 5_000, + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + }); + keySets.set(issuer, keys); + } + return keys; +} + const REJECTED_TOKEN_ERRORS = [ errors.JWTClaimValidationFailed, errors.JWTExpired, @@ -200,7 +227,7 @@ async function verifyAccessToken( ): Promise { let payload: unknown; try { - ({ payload } = await jwtVerify(token, config.keys, { + ({ payload } = await jwtVerify(token, issuerKeys(config.issuer), { issuer: config.issuer, audience, algorithms: ["RS256"], @@ -210,12 +237,8 @@ async function verifyAccessToken( })); } catch (error) { if (REJECTED_TOKEN_ERRORS.some((type) => error instanceof type)) - throw rejected( - `native access token rejected: ${(error as Error).message}`, - ); - throw unavailable( - `native issuer keys unavailable: ${error instanceof Error ? error.message : error}`, - ); + throw rejected(`native access token rejected: ${reason(error)}`); + throw unavailable(`native issuer keys unavailable: ${reason(error)}`); } const claims = accessClaimsSchema.safeParse(payload); if (!claims.success) @@ -226,14 +249,57 @@ async function verifyAccessToken( return claims.data; } +// Verifies an exchanged token and its lineage to the incoming token. A +// failure means the issuer and this server disagree, not a bad caller. +async function verifyExchangedToken( + token: string, + parent: AccessClaims, + config: NativeOAuthConfig, +): Promise { + let derived: AccessClaims; + try { + derived = await verifyAccessToken( + token, + config.apiAudience, + EXCHANGE_TOKEN_LIFETIME_SECONDS, + config, + ); + } catch (error) { + if ( + error instanceof NativeOAuthError && + error.failure.status === "unavailable" + ) + throw error; + throw serverFault(`exchanged token rejected: ${reason(error)}`); + } + const parentScopes = parent.scope.split(" "); + const lineage = { + sub: derived.sub === parent.sub, + grant_id: derived.grant_id === parent.grant_id, + org_id: derived.org_id === parent.org_id, + project_id: (derived.project_id ?? "") === (parent.project_id ?? ""), + client_id: derived.client_id === config.clientId, + act: derived.act?.sub === `oauth_client:${config.clientId}`, + parent_jti: derived.parent_jti === parent.jti, + exp: derived.exp <= parent.exp, + scope: derived.scope + .split(" ") + .every((item) => parentScopes.includes(item)), + }; + const failed = Object.entries(lineage) + .filter(([, ok]) => !ok) + .map(([check]) => check); + if (failed.length > 0) + throw serverFault(`exchanged token lineage mismatch: ${failed.join(",")}`); + return derived; +} + async function readExchangeResponse(response: Response): Promise { let text: string; try { text = await response.text(); } catch (error) { - throw unavailable( - `native exchange failed: ${error instanceof Error ? error.message : error}`, - ); + throw unavailable(`native exchange failed: ${reason(error)}`); } if (text.length > 16_384) throw unavailable("native exchange response too large"); @@ -244,9 +310,9 @@ async function readExchangeResponse(response: Response): Promise { } } -// RFC 8693 errors from the issuer: an inactive grant rejects the credential, -// a scope it no longer covers is forbidden, and anything that names this -// server's request or client is a deployment fault no retry will fix. +// RFC 8693 errors from the issuer: an inactive grant rejects the credential +// and a scope it no longer covers is forbidden. Anything naming this +// server's request or client is a server fault, logged as an error. function exchangeFailure(status: number, body: unknown): NativeOAuthError { const error = z.object({ error: z.string() }).safeParse(body).data?.error; if (error === "invalid_grant") @@ -258,18 +324,16 @@ function exchangeFailure(status: number, body: unknown): NativeOAuthError { ); if (status >= 500 || error === "temporarily_unavailable") return unavailable(`native exchange unavailable: HTTP ${status}`); - return misconfigured( + return serverFault( `native exchange refused this server: HTTP ${status} ${error ?? "without an OAuth error"}`, ); } -type CachedCredential = NativeCredential & { reuseUntil: number }; - async function exchange( token: string, parent: AccessClaims, config: NativeOAuthConfig, -): Promise { +): Promise { const startedAt = Date.now(); let response: Response; try { @@ -292,56 +356,18 @@ async function exchange( signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS), }); } catch (error) { - throw unavailable( - `native exchange failed: ${error instanceof Error ? error.message : error}`, - ); + throw unavailable(`native exchange failed: ${reason(error)}`); } const body = await readExchangeResponse(response); if (!response.ok) throw exchangeFailure(response.status, body); const exchanged = exchangeSchema.safeParse(body); if (!exchanged.success) - throw misconfigured("native exchange response is not RFC 8693"); + throw serverFault("native exchange response is not RFC 8693"); const { access_token: derivedToken, expires_in, scope } = exchanged.data; - let derived: AccessClaims; - try { - derived = await verifyAccessToken( - derivedToken, - config.apiAudience, - EXCHANGE_TOKEN_LIFETIME_SECONDS, - config, - ); - } catch (error) { - if ( - error instanceof NativeOAuthError && - error.failure.status === "unavailable" - ) - throw error; - throw misconfigured( - `exchanged token rejected: ${error instanceof Error ? error.message : error}`, - ); - } - const parentScopes = parent.scope.split(" "); - const lineage = { - sub: derived.sub === parent.sub, - grant_id: derived.grant_id === parent.grant_id, - org_id: derived.org_id === parent.org_id, - project_id: (derived.project_id ?? "") === (parent.project_id ?? ""), - client_id: derived.client_id === config.clientId, - act: derived.act?.sub === `oauth_client:${config.clientId}`, - parent_jti: derived.parent_jti === parent.jti, - exp: derived.exp <= parent.exp, - scope: - derived.scope === scope && - scope.split(" ").every((item) => parentScopes.includes(item)), - }; - const failed = Object.entries(lineage) - .filter(([, ok]) => !ok) - .map(([check]) => check); - if (failed.length > 0) - throw misconfigured( - `exchanged token lineage mismatch: ${failed.join(",")}`, - ); + const derived = await verifyExchangedToken(derivedToken, parent, config); + if (derived.scope !== scope) + throw serverFault("exchanged token lineage mismatch: scope"); // The relative lifetime covers an issuer clock running ahead, the signed // exp one running behind; lineage already bounds exp by the parent's. @@ -357,65 +383,166 @@ async function exchange( grantId: parent.grant_id, scopes: scope.split(" "), expiresAt, - reuseUntil: expiresAt - DERIVED_TOKEN_MIN_REMAINING_MS, }; } -// Exchanged tokens keyed by a SHA-256 fingerprint of the incoming token, so -// neither bearer ever becomes a key. Losing an entry only costs an exchange; -// the API still checks the exchanged token's status on every request. -const derivedTokens = new Map(); -const pendingExchanges = new Map>(); - -function rememberDerivedToken(key: string, credential: CachedCredential) { - const now = Date.now(); - if (credential.reuseUntil <= now) return; - for (const [entryKey, entry] of derivedTokens) - if (entry.reuseUntil <= now) derivedTokens.delete(entryKey); - if (derivedTokens.size >= MAX_DERIVED_TOKENS) { - const oldest = derivedTokens.keys().next().value; - if (oldest) derivedTokens.delete(oldest); +// Exchanged tokens are cached in Redis so every instance can reuse them. The +// key is an HMAC of the incoming token; the value is encrypted with a key +// derived from the incoming token, so Redis contents alone are useless. +export type NativeExchangeStore = { + get(cacheIdentity: string): Promise; + set(cacheIdentity: string, value: string, ttlMs: number): Promise; +}; + +const redisExchangeStore: NativeExchangeStore = { + get: (cacheIdentity) => getNativeExchange(cacheIdentity), + set: (cacheIdentity, value, ttlMs) => + setNativeExchange(cacheIdentity, value, ttlMs), +}; +let exchangeStore = redisExchangeStore; + +function cacheKey(token: string): Buffer { + return Buffer.from( + hkdfSync("sha256", token, Buffer.alloc(0), CACHE_KEY_INFO, 32), + ); +} + +function sealExchange( + token: string, + cacheIdentity: string, + credential: NativeCredential, +): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", cacheKey(token), iv); + cipher.setAAD(Buffer.from(cacheIdentity)); + const plaintext = JSON.stringify({ + token: credential.token, + expiresAt: credential.expiresAt, + }); + const sealed = Buffer.concat([ + iv, + cipher.update(plaintext, "utf8"), + cipher.final(), + cipher.getAuthTag(), + ]); + return sealed.toString("base64url"); +} + +function openExchange(token: string, cacheIdentity: string, value: string) { + const sealed = Buffer.from(value, "base64url"); + const decipher = createDecipheriv( + "aes-256-gcm", + cacheKey(token), + sealed.subarray(0, 12), + ); + decipher.setAAD(Buffer.from(cacheIdentity)); + decipher.setAuthTag(sealed.subarray(sealed.length - 16)); + const plaintext = Buffer.concat([ + decipher.update(sealed.subarray(12, sealed.length - 16)), + decipher.final(), + ]).toString("utf8"); + return cachedExchangeSchema.parse(JSON.parse(plaintext)); +} + +// A cached exchange is used only if it decrypts, still has the minimum +// remaining lifetime, and verifies again against the incoming token. Any +// miss, store error or failed check means a fresh exchange. +async function cachedExchange( + token: string, + cacheIdentity: string, + parent: AccessClaims, + config: NativeOAuthConfig, +): Promise { + let value: string | null; + try { + value = await Promise.race([ + exchangeStore.get(cacheIdentity), + new Promise((resolve) => + setTimeout(resolve, CACHE_READ_TIMEOUT_MS, null), + ), + ]); + } catch (error) { + console.warn(`native exchange cache unavailable: ${reason(error)}`); + return null; + } + if (!value) return null; + try { + const cached = openExchange(token, cacheIdentity, value); + if (cached.expiresAt - DERIVED_TOKEN_MIN_REMAINING_MS <= Date.now()) + return null; + const derived = await verifyExchangedToken(cached.token, parent, config); + return { + token: cached.token, + subject: parent.sub, + grantId: parent.grant_id, + scopes: derived.scope.split(" "), + expiresAt: Math.min(cached.expiresAt, derived.exp * 1000), + }; + } catch (error) { + if ( + error instanceof NativeOAuthError && + error.failure.status === "unavailable" && + !error.serverFault + ) + throw error; + console.warn(`native exchange cache entry discarded: ${reason(error)}`); + return null; } - derivedTokens.set(key, credential); } -async function derivedCredential( +// Concurrent requests on one instance share an exchange for the same token. +const pendingExchanges = new Map>(); + +async function exchangedCredential( token: string, parent: AccessClaims, config: NativeOAuthConfig, -): Promise { - const key = createHash("sha256") - .update([config.issuer, config.audience, token].join("\0")) - .digest("hex"); - const cached = derivedTokens.get(key); - if (cached && cached.reuseUntil > Date.now()) return cached; - if (cached) derivedTokens.delete(key); - - let pending = pendingExchanges.get(key); +): Promise { + const cacheIdentity = [config.issuer, config.audience, token].join("\0"); + const cached = await cachedExchange(token, cacheIdentity, parent, config); + if (cached) return cached; + + let pending = pendingExchanges.get(cacheIdentity); if (!pending) { - pending = exchange(token, parent, config) - .then((credential) => { - rememberDerivedToken(key, credential); - return credential; - }) - .finally(() => pendingExchanges.delete(key)); - pendingExchanges.set(key, pending); + pending = exchange(token, parent, config).finally(() => + pendingExchanges.delete(cacheIdentity), + ); + pendingExchanges.set(cacheIdentity, pending); + void pending.then( + (credential) => { + const ttlMs = + credential.expiresAt - DERIVED_TOKEN_MIN_REMAINING_MS - Date.now(); + if (ttlMs <= 0) return; + return exchangeStore + .set( + cacheIdentity, + sealExchange(token, cacheIdentity, credential), + ttlMs, + ) + .catch((error) => + console.warn( + `native exchange cache write failed: ${reason(error)}`, + ), + ); + }, + () => {}, + ); } return pending; } // Authenticates a native bearer on every request: the incoming token is // verified locally before any cached exchange is used, and its exchanged -// API-audience token is what the request carries onward. +// API-audience token is what the request carries onward. Without native +// configuration every native token is rejected like any untrusted token. export async function authenticateNativeOAuth( token: string, - request: Request, ): Promise { try { + const config = nativeOAuthConfig(); + if (!config) throw rejected("native OAuth is not configured"); if (token.length > MAX_TOKEN_BYTES) throw rejected("native access token too large"); - const config = nativeOAuthConfig(request); - if (!config) throw rejected("no native issuer is trusted for this origin"); const parent = await verifyAccessToken( token, config.audience, @@ -427,18 +554,19 @@ export async function authenticateNativeOAuth( throw rejected("native access token is already exchanged"); return { status: "ok", - credential: await derivedCredential(token, parent, config), + credential: await exchangedCredential(token, parent, config), }; } catch (error) { if (!(error instanceof NativeOAuthError)) throw error; - if (error.failure.status === "misconfigured") console.error(error.message); + if (error.serverFault) console.error(error.message); else console.warn(error.message); return error.failure; } } -export function clearNativeOAuthCachesForTests() { +export function resetNativeOAuthForTests(store?: NativeExchangeStore) { + resolvedConfig = undefined; keySets.clear(); - derivedTokens.clear(); pendingExchanges.clear(); + exchangeStore = store ?? redisExchangeStore; } diff --git a/src/lib/oauth-discovery-route.test.ts b/src/lib/oauth-discovery-route.test.ts index dfa40bd6..d860c8d0 100644 --- a/src/lib/oauth-discovery-route.test.ts +++ b/src/lib/oauth-discovery-route.test.ts @@ -32,8 +32,8 @@ describe("/.well-known/oauth-protected-resource/mcp", () => { token_endpoint: `${issuer}/token`, registration_endpoint: `${issuer}/register`, scopes_supported: ["openid"], - resource_name: "Kernel", - resource_documentation: "https://www.kernel.sh/docs/reference/mcp-server", + resource_name: "KERNEL", + resource_documentation: "https://www.kernel.sh/docs", }); }); diff --git a/src/lib/oauth-discovery.test.ts b/src/lib/oauth-discovery.test.ts index c851a6e2..c0f374a0 100644 --- a/src/lib/oauth-discovery.test.ts +++ b/src/lib/oauth-discovery.test.ts @@ -56,8 +56,8 @@ describe("OAuth protected-resource discovery", () => { token_endpoint: "https://auth.onkernel.com/token", registration_endpoint: "https://auth.onkernel.com/register", scopes_supported: ["openid"], - resource_name: "Kernel", - resource_documentation: "https://www.kernel.sh/docs/reference/mcp-server", + resource_name: "KERNEL", + resource_documentation: "https://www.kernel.sh/docs", }); }); diff --git a/src/lib/oauth-discovery.ts b/src/lib/oauth-discovery.ts index f4d01139..04a9b527 100644 --- a/src/lib/oauth-discovery.ts +++ b/src/lib/oauth-discovery.ts @@ -1,5 +1,3 @@ -import { documentation_url } from "../../server.json"; - const MCP_ORIGIN = "https://mcp.onkernel.com"; const DEV_MCP_ORIGIN = "https://mcp.dev.onkernel.com"; @@ -35,6 +33,12 @@ export function oauthRequestOrigin(request: Request): string { return url.origin; } +// This server's own public origin, as deployed. MANAGED_AUTH_APP_ORIGIN +// already names it for the managed-auth app. +export function mcpServerOrigin(): string { + return new URL(process.env.MANAGED_AUTH_APP_ORIGIN ?? MCP_ORIGIN).origin; +} + export function kernelDeployment( origin: string, ): { authorizationServer: string; api: string } | undefined { @@ -59,7 +63,7 @@ export function oauthResourceMetadata( token_endpoint: `${authorizationServer}/token`, registration_endpoint: `${authorizationServer}/register`, scopes_supported: ["openid"], - resource_name: "Kernel", - resource_documentation: documentation_url, + resource_name: "KERNEL", + resource_documentation: "https://www.kernel.sh/docs", }; } diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 59e66017..e46149cc 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -187,6 +187,35 @@ export async function getAuthorizationContextForClientId({ export { client as redisClient }; +// Exchanged native OAuth tokens. The key is an HMAC of the incoming token's +// cache identity and the value is ciphertext only its bearer can open; see +// native-oauth.ts. +function nativeExchangeKey(cacheIdentity: string): string { + return `native-exchange:${hashOpaqueToken(cacheIdentity)}`; +} + +export async function getNativeExchange( + cacheIdentity: string, +): Promise { + await ensureConnected(); + return await withReconnect(() => + client.get(nativeExchangeKey(cacheIdentity)), + ); +} + +export async function setNativeExchange( + cacheIdentity: string, + value: string, + ttlMs: number, +): Promise { + await ensureConnected(); + await withReconnect(() => + client.set(nativeExchangeKey(cacheIdentity), value, { + expiration: { type: "PX", value: Math.floor(ttlMs) }, + }), + ); +} + // MCP Apps capability markers. Streamable HTTP creates one McpServer per // request, so initialize capability must survive in Redis. The key combines // the authenticated subject with the server-signed MCP transport session; From 1833abf294b1c93c0c90eb197974c93d8de63b75 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:00:35 +0000 Subject: [PATCH 10/12] Fall back to an exchange on any cached-token failure; log malformed issuer answers as errors --- src/lib/native-oauth.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/native-oauth.ts | 17 +++++++++-------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 4c70692b..31a0eca0 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -352,6 +352,34 @@ describe("native token exchange", () => { } }); + test("logs a malformed issuer answer as an error and an outage as a warning", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await authenticate(await issuer.parent()); + for (const [status, body, logged] of [ + [200, "", error], + [400, "x".repeat(20_000), error], + [502, "", warn], + ] as const) { + error.mockClear(); + warn.mockClear(); + fetchSpy.mockImplementation((async (input: string | URL | Request) => + String(input).endsWith("/token") + ? new Response(body, { status }) + : issuer.request(String(input), {})) as never); + expect(await authenticate(await issuer.parent())).toEqual({ + status: "unavailable", + }); + expect(logged).toHaveBeenCalledTimes(1); + expect(logged === error ? warn : error).not.toHaveBeenCalled(); + } + } finally { + error.mockRestore(); + warn.mockRestore(); + } + }); + test("a revoked grant is rejected and never retried elsewhere", async () => { const parent = await issuer.parent(); issuer.revoked.add(String(decodeJwt(parent).jti)); @@ -400,6 +428,7 @@ describe("native token exchange", () => { "scope", ], [{}, { scope: "openid" }, "scope"], + [{}, { token_type: undefined }, "RFC 8693"], [ {}, { issued_token_type: "urn:ietf:params:oauth:token-type:jwt" }, diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 1e99de00..303a5faa 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -294,6 +294,8 @@ async function verifyExchangedToken( return derived; } +// A body that is not the issuer's JSON is an outage when the issuer says so +// (5xx), and otherwise an issuer fault. async function readExchangeResponse(response: Response): Promise { let text: string; try { @@ -301,12 +303,17 @@ async function readExchangeResponse(response: Response): Promise { } catch (error) { throw unavailable(`native exchange failed: ${reason(error)}`); } + const malformed = response.status >= 500 ? unavailable : serverFault; if (text.length > 16_384) - throw unavailable("native exchange response too large"); + throw malformed( + `native exchange response too large: HTTP ${response.status}`, + ); try { return JSON.parse(text); } catch { - throw unavailable(`native exchange returned HTTP ${response.status}`); + throw malformed( + `native exchange returned HTTP ${response.status} without JSON`, + ); } } @@ -479,12 +486,6 @@ async function cachedExchange( expiresAt: Math.min(cached.expiresAt, derived.exp * 1000), }; } catch (error) { - if ( - error instanceof NativeOAuthError && - error.failure.status === "unavailable" && - !error.serverFault - ) - throw error; console.warn(`native exchange cache entry discarded: ${reason(error)}`); return null; } From fd563668ae722ee351bafb77e011364fd989b923 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:08:58 +0000 Subject: [PATCH 11/12] Clear the exchanged-token cache read timer --- src/lib/native-oauth.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 303a5faa..5bf7426e 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -461,16 +461,19 @@ async function cachedExchange( config: NativeOAuthConfig, ): Promise { let value: string | null; + let timer: ReturnType | undefined; try { value = await Promise.race([ exchangeStore.get(cacheIdentity), - new Promise((resolve) => - setTimeout(resolve, CACHE_READ_TIMEOUT_MS, null), - ), + new Promise((resolve) => { + timer = setTimeout(resolve, CACHE_READ_TIMEOUT_MS, null); + }), ]); } catch (error) { console.warn(`native exchange cache unavailable: ${reason(error)}`); return null; + } finally { + clearTimeout(timer); } if (!value) return null; try { From ea2dee25c44b4f9987b118cce6cf90519ac36019 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:22:34 +0000 Subject: [PATCH 12/12] Describe native credential refusals accurately and flag stale local origins --- .env.example | 4 +++- src/app/[transport]/route.test.ts | 8 +++++++- src/app/[transport]/route.ts | 5 +++-- src/lib/mcp/auth-context.ts | 2 +- src/lib/native-oauth.test.ts | 29 +++++++++++++++-------------- src/lib/native-oauth.ts | 17 ++++++++++++++--- 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index c48fc904..1c6c3834 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,9 @@ API_BASE_URL= # Public origin of this MCP server. The Managed Auth MCP App connects only to # the narrowly scoped same-origin relay, and native OAuth trust follows it. -# Defaults to https://mcp.onkernel.com. +# Defaults to https://mcp.onkernel.com. A .env.local copied before native OAuth +# may still set the production origin; change it to localhost or the dev server +# will not start without the production exchange client. MANAGED_AUTH_APP_ORIGIN=http://localhost:3002 # Mintlify API Configuration - Only needed for the search_docs tool call diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 0baa7a89..91b5a7f9 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -343,7 +343,10 @@ describe("native OAuth credentials", () => { issuer.revoked.add(String(decodeJwt(revokedParent).jti)); let response = await POST(mcpRequest(revokedParent)); expect(response.status).toBe(401); - expect((await response.json()).error).toBe("invalid_token"); + expect(await response.json()).toEqual({ + error: "invalid_token", + error_description: "Invalid or expired access token", + }); fetchSpy.mockImplementation((async (input: string | URL | Request) => String(input).endsWith("/token") @@ -354,6 +357,9 @@ describe("native OAuth credentials", () => { expect(response.headers.get("WWW-Authenticate")).toBe( 'Bearer realm="OAuth", error="insufficient_scope"', ); + expect((await response.json()).error_description).toBe( + "This credential's scope does not cover this request", + ); expect(kernelTokens).toEqual([]); }); diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index bd89f283..d9fede43 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -143,7 +143,8 @@ export function connectionScopeFailureResponse( return errorResponse( 403, "insufficient_scope", - "This credential is not scoped to the requested Kernel project", + failure.description ?? + "This credential is not scoped to the requested Kernel project", { "WWW-Authenticate": `Bearer realm="OAuth", error="insufficient_scope"`, }, @@ -158,7 +159,7 @@ export function connectionScopeFailureResponse( return createAuthErrorResponse( req, "invalid_token", - "The Kernel API rejected this credential", + failure.description ?? "The Kernel API rejected this credential", ); } } diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts index 79b264b6..5963d76b 100644 --- a/src/lib/mcp/auth-context.ts +++ b/src/lib/mcp/auth-context.ts @@ -64,7 +64,7 @@ type ResolveAuthContextOptions = { }; export type McpConnectionContextFailure = - | { status: "rejected"; statusCode: 401 | 403 | 404 } + | { status: "rejected"; statusCode: 401 | 403 | 404; description?: string } | { status: "unavailable"; statusCode?: number } | { status: "invalid" }; diff --git a/src/lib/native-oauth.test.ts b/src/lib/native-oauth.test.ts index 31a0eca0..bca2cb3c 100644 --- a/src/lib/native-oauth.test.ts +++ b/src/lib/native-oauth.test.ts @@ -19,6 +19,12 @@ import { nativeIssuer, } from "./native-oauth.test-fixtures"; +const invalidToken = { + status: "rejected", + statusCode: 401, + description: "Invalid or expired access token", +} as const; + const ENV_KEYS = [ "MANAGED_AUTH_APP_ORIGIN", "API_BASE_URL", @@ -275,10 +281,7 @@ describe("native token exchange", () => { await issuer.parent({ pad: "x".repeat(4096) }), ]; for (const token of rejected) - expect(await authenticate(token)).toEqual({ - status: "rejected", - statusCode: 401, - }); + expect(await authenticate(token)).toEqual(invalidToken); expect(issuer.requests).toEqual([]); }); @@ -305,11 +308,15 @@ describe("native token exchange", () => { const cases: [() => Promise, unknown][] = [ [ async () => Response.json({ error: "invalid_grant" }, { status: 400 }), - { status: "rejected", statusCode: 401 }, + invalidToken, ], [ async () => Response.json({ error: "invalid_scope" }, { status: 400 }), - { status: "rejected", statusCode: 403 }, + { + status: "rejected", + statusCode: 403, + description: "This credential's scope does not cover this request", + }, ], [ async () => Response.json({ error: "invalid_target" }, { status: 400 }), @@ -383,10 +390,7 @@ describe("native token exchange", () => { test("a revoked grant is rejected and never retried elsewhere", async () => { const parent = await issuer.parent(); issuer.revoked.add(String(decodeJwt(parent).jti)); - expect(await authenticate(parent)).toEqual({ - status: "rejected", - statusCode: 401, - }); + expect(await authenticate(parent)).toEqual(invalidToken); expect(issuer.requests).toHaveLength(1); }); @@ -512,10 +516,7 @@ describe("native configuration", () => { API_BASE_URL: "https://api.dev.onkernel.com", }); expect(nativeOAuthConfig()).toBeNull(); - expect(await authenticate(await issuer.parent())).toEqual({ - status: "rejected", - statusCode: 401, - }); + expect(await authenticate(await issuer.parent())).toEqual(invalidToken); expect(fetchSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/native-oauth.ts b/src/lib/native-oauth.ts index 5bf7426e..b93f7354 100644 --- a/src/lib/native-oauth.ts +++ b/src/lib/native-oauth.ts @@ -74,7 +74,7 @@ export type NativeCredential = { export type NativeOAuthResult = | { status: "ok"; credential: NativeCredential } - | { status: "rejected"; statusCode: 401 | 403 } + | { status: "rejected"; statusCode: 401 | 403; description: string } | { status: "unavailable" }; type NativeOAuthFailure = Exclude; @@ -91,7 +91,14 @@ class NativeOAuthError extends Error { } const rejected = (message: string) => - new NativeOAuthError({ status: "rejected", statusCode: 401 }, message); + new NativeOAuthError( + { + status: "rejected", + statusCode: 401, + description: "Invalid or expired access token", + }, + message, + ); const unavailable = (message: string) => new NativeOAuthError({ status: "unavailable" }, message); const serverFault = (message: string) => @@ -326,7 +333,11 @@ function exchangeFailure(status: number, body: unknown): NativeOAuthError { return rejected("native exchange: grant inactive"); if (error === "invalid_scope") return new NativeOAuthError( - { status: "rejected", statusCode: 403 }, + { + status: "rejected", + statusCode: 403, + description: "This credential's scope does not cover this request", + }, "native exchange: scope not granted", ); if (status >= 500 || error === "temporarily_unavailable")