Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .specs/mcp-gateway-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ Kilo v1 is intentionally a two-plane system:
upstream credential injection, streaming proxying, per-instance refresh
coordination, runtime telemetry, and maintenance cleanup.

Root `/mcp` is a separate first-party native MCP resource owned by `apps/web`. It
MUST use native `token_use="native_mcp"` JWTs with the exact native resource as
audience and MUST NOT reuse Gateway scoped-route claims, configs, connection
instances, provider grants, or Worker runtime semantics.

This document supersedes the earlier clean-room baseline/profile split. There is
one in-repo contract for Kilo v1, not a baseline plus override layer.

Expand Down Expand Up @@ -105,6 +110,7 @@ when they appear in all capitals.
| Surface | Owner | Required behavior |
|---|---|---|
| `GET /health` | Worker | Health response only. |
| `POST /mcp` | App | First-party native MCP server for individual read-only stats; requires native `mcp:access` OAuth token and rejects Gateway/Kilo API tokens. |
| `GET` or `POST /mcp-connect/user/{user_id}/{config_id}/{route_key}` | Worker | Protected runtime entrypoint. Unauthenticated callers receive an OAuth challenge; authorized callers are proxied. |
| `GET` or `POST /mcp-connect/org/{org_id}/{config_id}/{route_key}` | Worker | Same as personal route, with org eligibility checks. |
| Descendants under scoped connect routes | Worker | Allowed only when config path passthrough is enabled and authorized against the root route. |
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
"@kilocode/event-service": "workspace:*",
"@kilocode/kilo-chat": "workspace:*",
"@kilocode/kilo-chat-hooks": "workspace:*",
"@kilocode/mcp-gateway": "workspace:*",
"@kilocode/kiloclaw-instance-tiers": "workspace:*",
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
"@kilocode/mcp-gateway": "workspace:*",
"@kilocode/organization-entitlement": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"@linear/sdk": "76.0.0",
Expand All @@ -62,6 +62,7 @@
"@mdx-js/react": "3.1.1",
"@mdxeditor/editor": "3.55.0",
"@mistralai/mistralai": "1.15.1",
"@modelcontextprotocol/sdk": "1.27.1",
"@monaco-editor/react": "4.7.0",
"@next/bundle-analyzer": "16.2.6",
"@next/mdx": "16.2.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { createGatewayServices } from '@/lib/mcp-gateway/services';
import { GatewaySupportedScopes } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

export async function GET() {
const { config } = createGatewayServices();
return NextResponse.json({
issuer: config.appBaseUrl,
authorization_endpoint: new URL(
'/api/mcp-gateway/oauth/authorize',
config.appBaseUrl
).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', config.appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', config.appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', config.appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', config.appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
});
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return NextResponse.json(
{
issuer: appBaseUrl,
authorization_endpoint: new URL('/api/mcp-gateway/oauth/authorize', appBaseUrl).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
resource_indicators_supported: true,
},
{
headers: { 'Cache-Control': 'no-store' },
}
);
}
24 changes: 12 additions & 12 deletions apps/web/src/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { createGatewayServices } from '@/lib/mcp-gateway/services';
import { GatewaySupportedScopes } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

function authorizationServerMetadata() {
const { config } = createGatewayServices();
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return {
issuer: config.appBaseUrl,
authorization_endpoint: new URL(
'/api/mcp-gateway/oauth/authorize',
config.appBaseUrl
).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', config.appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', config.appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', config.appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', config.appBaseUrl).toString(),
issuer: appBaseUrl,
authorization_endpoint: new URL('/api/mcp-gateway/oauth/authorize', appBaseUrl).toString(),
token_endpoint: new URL('/api/mcp-gateway/oauth/token', appBaseUrl).toString(),
registration_endpoint: new URL('/api/mcp-gateway/oauth/register', appBaseUrl).toString(),
jwks_uri: new URL('/api/mcp-gateway/oauth/jwks.json', appBaseUrl).toString(),
userinfo_endpoint: new URL('/api/mcp-gateway/oauth/userinfo', appBaseUrl).toString(),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: GatewaySupportedScopes,
resource_indicators_supported: true,
};
}

export async function GET() {
return NextResponse.json(authorizationServerMetadata());
return NextResponse.json(authorizationServerMetadata(), {
headers: { 'Cache-Control': 'no-store' },
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '../route';
11 changes: 11 additions & 0 deletions apps/web/src/app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { nativeMcpProtectedResourceMetadata } from '@kilocode/mcp-gateway';
import { APP_URL } from '@/lib/constants';

export async function GET() {
const appBaseUrl = process.env.MCP_GATEWAY_APP_BASE_URL || APP_URL;
return NextResponse.json(nativeMcpProtectedResourceMetadata(appBaseUrl), {
headers: { 'Cache-Control': 'no-store' },
});
}
62 changes: 62 additions & 0 deletions apps/web/src/app/api/mcp-gateway/oauth/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ const mockPreviewAuthorization = jest.fn<
>();
const mockAuthorize =
jest.fn<(params: unknown) => Promise<{ kind: 'provider_redirect'; authorizationUrl: string }>>();
const mockNativePreviewAuthorization = jest.fn<
(params: unknown) => Promise<{
clientId: string;
clientName: string;
redirectUri: string;
resource: string;
connectionName: string;
endpointHost: string;
contextName: string;
ownerScope: 'personal';
scopes: string[];
}>
>();
const mockNativeAuthorize =
jest.fn<(params: unknown) => Promise<{ kind: 'redirect'; redirectUrl: string }>>();
const mockRouteAuthorize = jest.fn();

jest.mock('@/lib/user/server', () => ({
Expand Down Expand Up @@ -58,6 +73,10 @@ jest.mock('@/lib/mcp-gateway/services', () => ({
previewAuthorization: mockPreviewAuthorization,
authorize: mockAuthorize,
},
nativeMcpAuthorizationService: {
previewAuthorization: mockNativePreviewAuthorization,
authorize: mockNativeAuthorize,
},
}),
}));

Expand Down Expand Up @@ -121,6 +140,20 @@ function authorizationUrl(redirectUri = 'http://127.0.0.1:60424/callback') {
return `http://localhost:3000/api/mcp-gateway/oauth/authorize?${query}`;
}

function nativeAuthorizationUrl(redirectUri = 'http://127.0.0.1:60424/callback') {
const query = new URLSearchParams({
client_id: 'mcp:client',
redirect_uri: redirectUri,
response_type: 'code',
resource: 'https://app.kilocode.ai/mcp',
scope: 'mcp:access',
state: 'client-state',
code_challenge: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~abcdefghijk',
code_challenge_method: 'S256',
});
return `http://localhost:3000/api/mcp-gateway/oauth/authorize?${query}`;
}

function approvalRequest(
approvalState: string,
cookie: string,
Expand Down Expand Up @@ -228,6 +261,35 @@ describe('POST /api/mcp-gateway/oauth/authorize', () => {
});

describe('GET /api/mcp-gateway/oauth/authorize', () => {
test('routes native MCP consent through the admin-only native branch', async () => {
mockGetUserFromAuth.mockResolvedValue({ user: mockUser, organizationId: undefined });
mockNativePreviewAuthorization.mockResolvedValue({
clientId: 'mcp:client',
clientName: 'Codex',
redirectUri: 'http://127.0.0.1:60424/callback',
resource: 'https://app.kilocode.ai/mcp',
connectionName: 'Kilo usage stats',
endpointHost: 'app.kilocode.ai',
contextName: 'Kilo admin preview',
ownerScope: 'personal',
scopes: ['mcp:access'],
});

const response = await loadedRoute().GET(new NextRequest(nativeAuthorizationUrl()));
if (!response) throw new Error('Expected native authorization response');
const document = await response.text();

expect(response.status).toBe(200);
expect(mockGetUserFromAuth).toHaveBeenCalledWith({ adminOnly: true });
expect(mockNativePreviewAuthorization).toHaveBeenCalledWith(
expect.objectContaining({ userId: mockUser.id, redirectErrors: true })
);
expect(mockPreviewAuthorization).not.toHaveBeenCalled();
expect(document).toContain('Allow access to your Kilo usage stats?');
expect(document).toContain('last 60 days per query');
expect(document).toContain('Read your Kilo stats');
});

test('shows unverified identity, effective access, callback, connection, context, and account', async () => {
const redirectUri = 'https://client.example/callback?source=mcp&mode=desktop';
mockGetUserFromAuth.mockResolvedValue({ user: mockUser, organizationId: undefined });
Expand Down
Loading