diff --git a/docs/configuration.md b/docs/configuration.md index d064465e..2ed489c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,6 +69,7 @@ Run `devspace init` to create both files. `devspace config set publicBaseUrl "accessTokenTtlSeconds": 3600, "refreshTokenTtlSeconds": 2592000, "scopes": ["devspace"], + "allowedResourceUrls": [], "allowedRedirectHosts": ["chatgpt.com", "localhost", "127.0.0.1"], }, } @@ -78,6 +79,16 @@ Omitted sections and keys use the defaults shown above. An empty `workspaces.allowedRoots` uses the current working directory. Unknown keys are rejected so spelling mistakes cannot silently alter behavior. +`oauth.allowedResourceUrls` accepts exact alternate MCP resource URLs for +clients that connect through a resource alias, such as a secure MCP tunnel. +The normal `server.publicBaseUrl` `/mcp` resource remains allowed automatically. +Configure the complete alias URL, not a hostname or origin; aliases do not +change OAuth discovery URLs or proxy routing. +Resource URLs must use HTTPS; HTTP is allowed only for `localhost`, `127.0.0.1`, +or `[::1]`, with optional ports. Restart DevSpace after changing +`oauth.allowedResourceUrls`: the provider reads this policy at server creation. +After restarting, refresh tokens for removed aliases can no longer mint tokens. + ## Tool modes and UI `tools.mode` accepts two values: diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index c4fa82d6..4c5fb62e 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -286,6 +286,15 @@ "minLength": 1 } }, + "allowedResourceUrls": { + "default": [], + "type": "array", + "items": { + "type": "string", + "format": "uri", + "description": "Exact resource URL: HTTPS, or HTTP on localhost, 127.0.0.1, or [::1]." + } + }, "allowedRedirectHosts": { "default": [ "chatgpt.com", diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts index 1b6ce988..4149f5d8 100644 --- a/src/config-schema.test.ts +++ b/src/config-schema.test.ts @@ -10,6 +10,27 @@ assert.throws( /Unrecognized key/, ); +for (const url of [ + "https://tunnel.example.com/v1/mcp/tunnel_123", + "http://localhost:7676/mcp", + "http://127.0.0.1:7676/mcp", + "http://[::1]:7676/mcp", +]) { + assert.doesNotThrow(() => devspaceConfigSchema.parse({ + configVersion: 1, oauth: { allowedResourceUrls: [url] }, + }), url); +} +for (const url of [ + "http://tunnel.example.com/mcp", "http://192.168.1.1/mcp", + "http://localhost.example.com/mcp", "http://127.0.0.1.example.com/mcp", + "http://[::2]/mcp", "ftp://localhost/mcp", "file:///mcp", + "custom://tunnel.example.com/mcp", "not-a-url", +]) { + assert.equal(devspaceConfigSchema.safeParse({ + configVersion: 1, oauth: { allowedResourceUrls: [url] }, + }).success, false, url); +} + const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; const committedSchema = readFileSync( new URL("../schema/v1/devspace.schema.json", import.meta.url), diff --git a/src/config-schema.ts b/src/config-schema.ts index 78e610f5..95141fbd 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -54,6 +54,13 @@ const oauthConfigSchema = z.object({ accessTokenTtlSeconds: z.number().int().positive().default(60 * 60), refreshTokenTtlSeconds: z.number().int().positive().default(30 * 24 * 60 * 60), scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), + allowedResourceUrls: z.array(z.string().trim().url().refine((value) => { + const url = URL.parse(value); + return url !== null && (url.protocol === "https:" + || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))); + }, "Resource URLs must use HTTPS, or HTTP on localhost, 127.0.0.1, or [::1]") + .describe("Exact resource URL: HTTPS, or HTTP on localhost, 127.0.0.1, or [::1].")) + .default([]), allowedRedirectHosts: z.array(z.string().trim().min(1)).min(1).default([ "chatgpt.com", "localhost", diff --git a/src/config.test.ts b/src/config.test.ts index d21b39e8..ddb2fc1c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -27,6 +27,7 @@ try { instructions: "on-demand", providers: [], }); + assert.deepEqual(defaults.oauth.allowedResourceUrls, []); assert.deepEqual(defaults.logging, { level: "info", format: "json", @@ -72,6 +73,7 @@ try { accessTokenTtlSeconds: 120, refreshTokenTtlSeconds: 240, scopes: ["devspace", "admin"], + allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com", "example.com"], }, }, env); @@ -105,6 +107,9 @@ try { assert.equal(configured.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(configured.oauth.accessTokenTtlSeconds, 120); assert.deepEqual(configured.oauth.scopes, ["devspace", "admin"]); + assert.deepEqual(configured.oauth.allowedResourceUrls, [ + "https://tunnel.example.com/v1/mcp/tunnel_123", + ]); assert.deepEqual(configured.logging, { level: "debug", format: "pretty", diff --git a/src/config.ts b/src/config.ts index e5330526..34fcdfc2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { accessTokenTtlSeconds: stored.oauth.accessTokenTtlSeconds, refreshTokenTtlSeconds: stored.oauth.refreshTokenTtlSeconds, scopes: stored.oauth.scopes, + allowedResourceUrls: stored.oauth.allowedResourceUrls, allowedRedirectHosts: stored.oauth.allowedRedirectHosts, }, allowedRoots: normalizePaths(stored.workspaces.allowedRoots, [process.cwd()]), diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e6503788..4fef9955 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -17,6 +17,7 @@ export interface OAuthConfig { accessTokenTtlSeconds: number; refreshTokenTtlSeconds: number; scopes: string[]; + allowedResourceUrls: string[]; allowedRedirectHosts: string[]; } @@ -116,6 +117,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { private readonly codes = new Map(); private readonly oauthStore: SqliteOAuthStore; private readonly resourceServerUrl: URL; + private readonly allowedResourceUrls: Set; constructor( private readonly config: OAuthConfig, @@ -123,6 +125,9 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { stateDir: string, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); + this.allowedResourceUrls = new Set( + config.allowedResourceUrls.map((url) => resourceUrlFromServerUrl(url).href), + ); this.oauthStore = new SqliteOAuthStore(stateDir); this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts); } @@ -132,7 +137,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { - if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { + if (!params.resource || !this.isResourceAllowed(params.resource)) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { @@ -199,7 +204,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (redirectUri && redirectUri !== record.params.redirectUri) { throw new InvalidGrantError("redirect_uri does not match the authorization request"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + if (resource && (!record.params.resource || !sameResource(resource, record.params.resource))) { throw new InvalidGrantError("Invalid resource"); } @@ -218,7 +223,11 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidGrantError("Invalid refresh token"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + const recordedResource = record.resource ? new URL(record.resource) : undefined; + if (!recordedResource || !this.isResourceAllowed(recordedResource)) { + throw new InvalidGrantError("Invalid resource"); + } + if (resource && !sameResource(resource, recordedResource)) { throw new InvalidGrantError("Invalid resource"); } @@ -230,7 +239,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return this.issueTokens( client.client_id, requestedScopes, - resource ?? (record.resource ? new URL(record.resource) : undefined), + resource ?? recordedResource, refreshTokenHash, ); } @@ -260,6 +269,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { this.oauthStore.close(); } + isResourceAllowed(resource: URL): boolean { + return checkResourceAllowed({ + requestedResource: resource, + configuredResource: this.resourceServerUrl, + }) || this.allowedResourceUrls.has(resourceUrlFromServerUrl(resource).href); + } + private validCodeRecord( client: OAuthClientInformationFull, authorizationCode: string, @@ -335,3 +351,7 @@ function authorizationFormFields( function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function sameResource(left: URL, right: URL): boolean { + return resourceUrlFromServerUrl(left).href === resourceUrlFromServerUrl(right).href; +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf..b6cc7993 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -14,9 +14,11 @@ const oauthConfig = { accessTokenTtlSeconds: 3600, refreshTokenTtlSeconds: 2592000, scopes: ["devspace"], + allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com"], }; const mcpUrl = new URL("https://agent.example.com/mcp"); +const tunnelUrl = new URL(oauthConfig.allowedResourceUrls[0]!); const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { @@ -25,6 +27,7 @@ try { testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); await testProviderRestartRotationAndRevocation(join(root, "provider")); + await testRefreshResourcePolicy(join(root, "refresh-policy")); } finally { await rm(root, { recursive: true, force: true }); } @@ -188,6 +191,11 @@ function testTransactionalTokenRotation(stateDir: string): void { async function testProviderRestartRotationAndRevocation(stateDir: string): Promise { const firstProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); + assert.equal(firstProvider.isResourceAllowed(mcpUrl), true); + assert.equal(firstProvider.isResourceAllowed(new URL(`${mcpUrl.href}/session`)), true); + assert.equal(firstProvider.isResourceAllowed(tunnelUrl), true); + assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}/session`)), false); + assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}?other=1`)), false); const client = await firstProvider.clientsStore.registerClient?.({ redirect_uris: [redirectUri], client_name: "ChatGPT", @@ -201,45 +209,73 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi redirectUri, codeChallenge: "challenge", scopes: ["devspace"], - resource: mcpUrl, + resource: tunnelUrl, }, expiresAtMs: Date.now() + 60_000, }); + await assert.rejects( + firstProvider.exchangeAuthorizationCode(client, code, undefined, redirectUri, mcpUrl), + InvalidGrantError, + ); const issued = await firstProvider.exchangeAuthorizationCode( client, code, undefined, redirectUri, - mcpUrl, + tunnelUrl, ); assert.ok(issued.refresh_token); firstProvider.close(); + const removedAliasProvider = new SingleUserOAuthProvider( + { ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir, + ); + try { + for (const resource of [undefined, tunnelUrl, mcpUrl]) { + await assert.rejects( + removedAliasProvider.exchangeRefreshToken(client, issued.refresh_token, undefined, resource), + InvalidGrantError, + ); + } + } finally { + removedAliasProvider.close(); + } + const secondProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); try { const verified = await secondProvider.verifyAccessToken(issued.access_token); assert.equal(verified.clientId, client.client_id); + assert.equal(verified.resource?.href, tunnelUrl.href); + + await assert.rejects( + secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], mcpUrl), + InvalidGrantError, + ); const refreshed = await secondProvider.exchangeRefreshToken( client, issued.refresh_token, ["devspace"], - mcpUrl, + tunnelUrl, ); assert.ok(refreshed.refresh_token); assert.notEqual(refreshed.access_token, issued.access_token); + const inherited = await secondProvider.exchangeRefreshToken(client, refreshed.refresh_token); + assert.ok(inherited.refresh_token); + assert.equal((await secondProvider.verifyAccessToken(inherited.access_token)).resource?.href, tunnelUrl.href); + await assert.rejects( - secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], mcpUrl), + secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); - await secondProvider.revokeToken(client, { token: refreshed.access_token }); - await assert.rejects(secondProvider.verifyAccessToken(refreshed.access_token), InvalidTokenError); + await secondProvider.revokeToken(client, { token: inherited.access_token }); + await assert.rejects(secondProvider.verifyAccessToken(inherited.access_token), InvalidTokenError); - await secondProvider.revokeToken(client, { token: refreshed.refresh_token }); + await secondProvider.revokeToken(client, { token: inherited.refresh_token }); await assert.rejects( - secondProvider.exchangeRefreshToken(client, refreshed.refresh_token, ["devspace"], mcpUrl), + secondProvider.exchangeRefreshToken(client, inherited.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); } finally { @@ -247,6 +283,26 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi } } +async function testRefreshResourcePolicy(stateDir: string): Promise { + const store = new SqliteOAuthStore(stateDir); + const client = store.registerClient({ redirect_uris: [redirectUri] }, oauthConfig.allowedRedirectHosts); + for (const [token, resource] of [["canonical", mcpUrl.href], ["missing", undefined]] as const) { + store.saveRefreshToken(hashToken(token), { + clientId: client.client_id, scopes: ["devspace"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, + }); + } + store.close(); + const provider = new SingleUserOAuthProvider({ ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir); + try { + await assert.rejects(provider.exchangeRefreshToken(client, "missing"), InvalidGrantError); + const tokens = await provider.exchangeRefreshToken(client, "canonical"); + assert.equal((await provider.verifyAccessToken(tokens.access_token)).resource?.href, mcpUrl.href); + } finally { + provider.close(); + } +} + function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } diff --git a/src/server-oauth.test.ts b/src/server-oauth.test.ts new file mode 100644 index 00000000..1ab4fcfb --- /dev/null +++ b/src/server-oauth.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadConfig } from "./config.js"; +import { SqliteOAuthStore } from "./oauth-store.js"; +import { createServer } from "./server.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; + +test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-http-oauth-")); + const canonical = "https://agent.example.com/mcp"; + const alias = "https://tunnel.example.com/v1/mcp/tunnel_123"; + const config = loadConfig(writeTestDevspaceConfig(join(root, "config"), { + server: { publicBaseUrl: "https://agent.example.com" }, + storage: { stateDir: join(root, "state") }, + workspaces: { allowedRoots: [root] }, + oauth: { allowedResourceUrls: [alias] }, + logging: { level: "silent" }, + })); + const store = new SqliteOAuthStore(config.stateDir); + const client = store.registerClient({ + redirect_uris: ["https://chatgpt.com/connector_platform_oauth_redirect"], + }, config.oauth.allowedRedirectHosts); + const cases = [ + { resource: canonical, accepted: true }, + { resource: alias, accepted: true }, + { resource: `${alias}/child`, accepted: false }, + { resource: `${alias}?other=1`, accepted: false }, + { resource: "https://tunnel.example.com/v1/mcp/other", accepted: false }, + ]; + for (const { resource } of cases) { + store.saveAccessToken(createHash("sha256").update(resource).digest("base64url"), { + clientId: client.client_id, scopes: ["devspace"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, + }); + } + store.close(); + const running = createServer(config); + const listener = running.app.listen(0, "127.0.0.1"); + t.after(async () => { + await running.close(); + listener.closeAllConnections(); + await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())); + await rm(root, { recursive: true, force: true }); + }); + await once(listener, "listening"); + const address = listener.address(); + assert.ok(address && typeof address !== "string"); + for (const { resource, accepted } of cases) { + const response: Response = await fetch(`http://127.0.0.1:${address.port}/mcp`, { + method: "POST", + headers: { + Authorization: `Bearer ${resource}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "resource-test", version: "1.0.0" } }, + }), + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + assert.equal(response.status, accepted ? 200 : 401, `${resource}: ${body}`); + if (accepted) assert.match(body, /"serverInfo"/); + } +}); diff --git a/src/server.ts b/src/server.ts index 68a74bc3..9783ce3e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; -import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; import { createMcpHandler } from "@modelcontextprotocol/server"; import { toNodeHandler } from "@modelcontextprotocol/node"; import { @@ -920,7 +920,7 @@ export function createServer( }); if (res.headersSent) return; - if (!req.auth?.resource || !checkResourceAllowed({ requestedResource: req.auth.resource, configuredResource: resourceServerUrl })) { + if (!req.auth?.resource || !oauthProvider.isResourceAllowed(req.auth.resource)) { logEvent(config.logging, "warn", "auth_denied", { requestId, method: req.method,