diff --git a/.env.example b/.env.example index 95f16a49..1c6c3834 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ +# 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 @@ -14,8 +21,11 @@ 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. 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 MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_ diff --git a/docs/native-oauth.md b/docs/native-oauth.md new file mode 100644 index 00000000..5fe416b5 --- /dev/null +++ b/docs/native-oauth.md @@ -0,0 +1,92 @@ +# Kernel-issued (native) OAuth + +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`). 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. 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 + `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 | +| ------------------------------------------------------------------------------------------------- | ----------------------------- | +| 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 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 + 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 + +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 | 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 8455bec3..91b5a7f9 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -1,8 +1,24 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { decodeJwt } from "jose"; +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"; 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"; @@ -19,8 +35,17 @@ 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 ) => { @@ -29,6 +54,39 @@ mock.module("@/lib/mcp/analytics", () => ({ }, })); +// 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 { hasMcpAppsClient } = redis; +mock.module("@/lib/redis", () => ({ + ...redis, + 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 + ) => + requestUsesMcpApps( + req, + identity, + store ?? (appMarkers ? memoryMarkers : undefined), + ), +})); + const originalCreateKernelClient = defaultMcpDependencies.createKernelClient; const { GET, POST, connectionScopeFailureResponse } = await import("./route"); @@ -75,6 +133,329 @@ afterEach(() => { defaultMcpDependencies.createKernelClient = originalCreateKernelClient; }); +const nativeEnvKeys = Object.keys((await nativeIssuer()).env()); + +describe("native OAuth credentials", () => { + 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 () => { + issuer = await nativeIssuer(); + Object.assign(process.env, issuer.env()); + resetNativeOAuthForTests(memoryExchangeStore().store); + kernelTokens = []; + kernelPaths = []; + browsersRequested = () => {}; + 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; + 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. + 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" }, + }, + }); + }, + }); + }; + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async ( + input: string | URL | Request, + init?: RequestInit, + ) => issuer.request(String(input), init ?? {})) as typeof fetch); + }); + + afterEach(() => { + appMarkers = null; + analyticsClient = null; + fetchSpy.mockRestore(); + for (const key of envKeys) + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + resetNativeOAuthForTests(); + }); + + 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, params }), + }); + } + + // 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 () => { + 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"); + expect(issuer.requests).toHaveLength(2); + }); + + test("reuse the entitlements snapshot across rotated tokens", async () => { + 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 () => { + 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("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 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=", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(kernelTokens).toEqual([]); + }); + + test("reach the API only as the exchanged API-audience token", async () => { + 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(decodeJwt(parent).jti); + } + }); + + 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(); + 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 () => { + 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()).toEqual({ + error: "invalid_token", + error_description: "Invalid or expired access token", + }); + + 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")).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([]); + }); + + test("issuer outages are retryable 503s", async () => { + fetchSpy.mockImplementation( + (async () => new Response(null, { status: 503 })) as never, + ); + const response = await POST(mcpRequest(await issuer.parent())); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("1"); + expect(kernelTokens).toEqual([]); + }); + + test("post-authentication bugs propagate as server errors", async () => { + 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", () => { + 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(); + expect(Date.now() - started).toBeGreaterThanOrEqual(900); + expect(Date.now()).toBeGreaterThanOrEqual(signedExpiry - 100); + expect(Date.now()).toBeLessThanOrEqual(signedExpiry + 250); + } + + 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", () => { for (const method of ["GET", "POST"]) { test.each([ diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 041eef43..d9fede43 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -7,6 +7,10 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; +import { + authenticateNativeOAuth, + isNativeOAuthCredential, +} from "@/lib/native-oauth"; import { oauthResourceMetadataUrl } from "@/lib/oauth-discovery"; import { captureMcpConnectionScopeFailure, @@ -139,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"`, }, @@ -154,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", ); } } @@ -194,6 +199,7 @@ async function handleMcpRequestWithIdentity({ credentialType, transportSessionId, connectionContextCacheIdentity, + entitlementsCacheCredential = token, observeConnection, }: { req: NextRequest; @@ -204,6 +210,7 @@ async function handleMcpRequestWithIdentity({ credentialType: "api_key" | "oauth"; transportSessionId: string | null; connectionContextCacheIdentity?: string; + entitlementsCacheCredential?: string; observeConnection: boolean; }) { const [mcpApps, connection] = await Promise.all([ @@ -239,7 +246,7 @@ async function handleMcpRequestWithIdentity({ authSubject, connection.context.authContext.organization.id, transportSessionId ?? "stateless", - token, + entitlementsCacheCredential, ].join("\0"), }) : { vaults: false, search: false }; @@ -284,6 +291,34 @@ async function handleAuthenticatedRequest( ); } + if (isNativeOAuthCredential(token)) { + const native = await authenticateNativeOAuth(token); + 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)) { // Opaque API keys are authenticated by the Kernel API rather than Clerk. // Do not cache their context: /auth/context must revalidate the credential 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/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/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 new file mode 100644 index 00000000..824db154 --- /dev/null +++ b/src/lib/native-oauth.test-fixtures.ts @@ -0,0 +1,182 @@ +import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from "jose"; + +const ACCESS_TOKEN_URN = "urn:ietf:params:oauth:token-type:access_token"; + +// 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 = { + issuer, + origin, + audience: `${origin}/mcp`, + apiAudience, + clientId: "kcl_mcp", + clientSecret: "secret+/=", + }; + + 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: crypto.randomUUID(), + 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: 1, + grant_authorization_epoch: 1, + registration_security_version: 1, + ...overrides, + }; + } + + const revoked = new Set(); + const requests: { url: string; init: RequestInit }[] = []; + const jwksRequests: string[] = []; + let derivedOverrides: Record = {}; + let responseOverrides: Record = {}; + + async function request(url: string, init: RequestInit): Promise { + 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); + 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") + ) + 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, + { 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: crypto.randomUUID(), + 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, + jwksRequests, + 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, + NATIVE_OAUTH_MCP_EXCHANGE_CLIENT_SECRET: config.clientSecret, + }), + deriveWith(overrides: Record) { + derivedOverrides = overrides; + }, + respondWith(overrides: Record) { + responseOverrides = overrides; + }, + }; +} + +// 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 new file mode 100644 index 00000000..bca2cb3c --- /dev/null +++ b/src/lib/native-oauth.test.ts @@ -0,0 +1,570 @@ +import { + afterEach, + beforeEach, + describe, + expect, + setSystemTime, + spyOn, + test, +} from "bun:test"; +import { decodeJwt, generateKeyPair, SignJWT } from "jose"; +import { + authenticateNativeOAuth, + isNativeOAuthCredential, + nativeOAuthConfig, + resetNativeOAuthForTests, +} from "./native-oauth"; +import { + memoryExchangeStore, + 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", + "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 cache: ReturnType; +let fetchSpy: ReturnType>; + +// 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, env); + newInstance(); +} + +const authenticate = (token: string) => authenticateNativeOAuth(token); + +beforeEach(async () => { + cache = memoryExchangeStore(); + issuer = await nativeIssuer(); + useEnv(issuer.env()); + 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]; + resetNativeOAuthForTests(); +}); + +describe("native credential detection", () => { + test("selects only at+jwt tokens, as the Kernel API does", async () => { + 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 token exchange", () => { + test("exchanges for the API audience with the RFC 8693 contract", async () => { + const parent = await issuer.parent(); + 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("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); + expect(first.status).toBe("ok"); + newInstance(); + expect(await authenticate(parent)).toEqual(first); + expect(issuer.requests).toHaveLength(1); + + // 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); + 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); + }); + + 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)), + ); + expect(new Set(results.map((result) => JSON.stringify(result))).size).toBe( + 1, + ); + expect(issuer.requests).toHaveLength(1); + }); + + 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 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); + expect(cache.entries.size).toBe(0); + await authenticate(parent); + expect(issuer.requests).toHaveLength(2); + }); + + 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("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"); + 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({ 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 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) + expect(await authenticate(token)).toEqual(invalidToken); + expect(issuer.requests).toEqual([]); + }); + + 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("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][] = [ + [ + async () => Response.json({ error: "invalid_grant" }, { status: 400 }), + invalidToken, + ], + [ + async () => Response.json({ error: "invalid_scope" }, { status: 400 }), + { + status: "rejected", + statusCode: 403, + description: "This credential's scope does not cover this request", + }, + ], + [ + async () => Response.json({ error: "invalid_target" }, { status: 400 }), + { status: "unavailable" }, + ], + [ + async () => + Response.json({ error: "unauthorized_client" }, { status: 400 }), + { status: "unavailable" }, + ], + [ + async () => Response.json({ error: "invalid_client" }, { status: 401 }), + { status: "unavailable" }, + ], + [ + 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" }, + ], + ]; + // 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("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)); + expect(await authenticate(parent)).toEqual(invalidToken); + expect(issuer.requests).toHaveLength(1); + }); + + test("an API audience the issuer does not serve is invalid_target", async () => { + useEnv({ ...issuer.env(), API_BASE_URL: "https://api.other.test" }); + expect(await authenticate(await issuer.parent())).toEqual({ + status: "unavailable", + }); + expect( + new URLSearchParams(String(issuer.requests[0]!.init.body)).get( + "resource", + ), + ).toBe("https://api.other.test"); + }); + + 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", + ], + [{}, { scope: "openid" }, "scope"], + [{}, { token_type: undefined }, "RFC 8693"], + [ + {}, + { 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: "unavailable", + }); + expect(String(errors.mock.calls[0]?.[0])).toContain(check); + } + } finally { + errors.mockRestore(); + } + }); +}); + +describe("native configuration", () => { + 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("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(invalidToken); + 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], + ["API_BASE_URL", undefined], + ["NATIVE_OAUTH_ISSUER", "http://auth.example.test"], + ["NATIVE_OAUTH_ISSUER", "https://auth.example.test/"], + ])("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("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 new file mode 100644 index 00000000..b93f7354 --- /dev/null +++ b/src/lib/native-oauth.ts @@ -0,0 +1,587 @@ +import { + createCipheriv, + createDecipheriv, + hkdfSync, + randomBytes, +} from "node:crypto"; +import { + createRemoteJWKSet, + decodeProtectedHeader, + errors, + jwtVerify, + type JWTVerifyGetKey, +} from "jose"; +import { z } from "zod"; +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. +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_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"; + +// 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 accessClaimsSchema = z.object({ + 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), + grant_id: z.string().min(1), + org_id: z.string().min(1), + project_id: z.string().optional(), + scope: z.string().min(1), + authorization_version: z.literal(1), + 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(MAX_TOKEN_BYTES), + token_type: z.literal("Bearer"), + issued_token_type: z.literal(ACCESS_TOKEN_URN), + expires_in: z.number().int().nonnegative(), + 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; + 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; description: string } + | { status: "unavailable" }; + +type NativeOAuthFailure = Exclude; + +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); + } +} + +const rejected = (message: string) => + 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) => + 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. +export function isNativeOAuthCredential(token: string): boolean { + if (token.split(".").length !== 3) return false; + try { + const typ = decodeProtectedHeader(token).typ?.toLowerCase(); + return ( + typ === ACCESS_TOKEN_TYPE || typ === `application/${ACCESS_TOKEN_TYPE}` + ); + } catch { + return false; + } +} + +export type NativeOAuthConfig = { + issuer: string; + audience: string; + apiAudience: string; + clientId: string; + clientSecret: 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 ?? "", + ); + if ( + !url || + url.origin !== value || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) + throw new Error("NATIVE_OAUTH_ISSUER must be an HTTPS or loopback origin"); + return value; +} + +// 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 { + 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; + + 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 new Error( + `Native OAuth for ${origin} requires API_BASE_URL to be ${apiAudience ?? "an API origin"}`, + ); + return { + issuer, + audience: `${origin}/mcp`, + apiAudience, + clientId, + clientSecret, + }; +} + +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, + 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 { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, issuerKeys(config.issuer), { + 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: ${reason(error)}`); + throw unavailable(`native issuer keys unavailable: ${reason(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; +} + +// 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; +} + +// 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 { + text = await response.text(); + } catch (error) { + throw unavailable(`native exchange failed: ${reason(error)}`); + } + const malformed = response.status >= 500 ? unavailable : serverFault; + if (text.length > 16_384) + throw malformed( + `native exchange response too large: HTTP ${response.status}`, + ); + try { + return JSON.parse(text); + } catch { + throw malformed( + `native exchange returned HTTP ${response.status} without JSON`, + ); + } +} + +// 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") + return rejected("native exchange: grant inactive"); + if (error === "invalid_scope") + return new NativeOAuthError( + { + 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") + return unavailable(`native exchange unavailable: HTTP ${status}`); + return serverFault( + `native exchange refused this server: HTTP ${status} ${error ?? "without an OAuth error"}`, + ); +} + +async function exchange( + token: string, + parent: AccessClaims, + config: NativeOAuthConfig, +): Promise { + const startedAt = Date.now(); + 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: ${reason(error)}`); + } + const body = await readExchangeResponse(response); + if (!response.ok) throw exchangeFailure(response.status, body); + + const exchanged = exchangeSchema.safeParse(body); + if (!exchanged.success) + throw serverFault("native exchange response is not RFC 8693"); + const { access_token: derivedToken, expires_in, scope } = exchanged.data; + 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. + 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 { + token: derivedToken, + subject: parent.sub, + grantId: parent.grant_id, + scopes: scope.split(" "), + expiresAt, + }; +} + +// 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; + let timer: ReturnType | undefined; + try { + value = await Promise.race([ + exchangeStore.get(cacheIdentity), + 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 { + 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) { + console.warn(`native exchange cache entry discarded: ${reason(error)}`); + return null; + } +} + +// 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 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).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. Without native +// configuration every native token is rejected like any untrusted token. +export async function authenticateNativeOAuth( + token: string, +): 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 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 exchangedCredential(token, parent, config), + }; + } catch (error) { + if (!(error instanceof NativeOAuthError)) throw error; + if (error.serverFault) console.error(error.message); + else console.warn(error.message); + return error.failure; + } +} + +export function resetNativeOAuthForTests(store?: NativeExchangeStore) { + resolvedConfig = undefined; + keySets.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 8e7d23d9..d860c8d0 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", }); }); diff --git a/src/lib/oauth-discovery.test.ts b/src/lib/oauth-discovery.test.ts index 4e7dd9ae..c0f374a0 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", }); }); diff --git a/src/lib/oauth-discovery.ts b/src/lib/oauth-discovery.ts index eb03315b..04a9b527 100644 --- a/src/lib/oauth-discovery.ts +++ b/src/lib/oauth-discovery.ts @@ -1,7 +1,22 @@ 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 +33,18 @@ 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 { + return KERNEL_DEPLOYMENTS[origin]; +} + export function oauthResourceMetadataUrl(request: Request): string { return `${oauthRequestOrigin(request)}${OAUTH_RESOURCE_METADATA_PATH}`; } @@ -26,9 +53,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 +63,7 @@ export function oauthResourceMetadata( token_endpoint: `${authorizationServer}/token`, registration_endpoint: `${authorizationServer}/register`, scopes_supported: ["openid"], + 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;