From 098d2acdf5f9c8f4f68ed19023f96295dd97c3a6 Mon Sep 17 00:00:00 2001 From: wcf778 <79058088+wcf778@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:41:54 +0800 Subject: [PATCH] fix(oauth): support exact resource aliases --- docs/configuration.md | 7 +++++++ schema/v1/devspace.schema.json | 8 ++++++++ src/config-schema.ts | 1 + src/config.test.ts | 5 +++++ src/config.ts | 1 + src/oauth-provider.ts | 25 +++++++++++++++++++++---- src/oauth-store.test.ts | 25 +++++++++++++++++++++---- src/server.ts | 4 ++-- 8 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..03ac34c31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,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"], }, } @@ -77,6 +78,12 @@ 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. + ## Tool modes and UI `tools.mode` accepts two values: diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index e7c18466e..22dd83452 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -277,6 +277,14 @@ "minLength": 1 } }, + "allowedResourceUrls": { + "default": [], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, "allowedRedirectHosts": { "default": [ "chatgpt.com", diff --git a/src/config-schema.ts b/src/config-schema.ts index c30bb3612..d64ec78f4 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -54,6 +54,7 @@ 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()).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 5fd24b490..71f746c48 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,6 +23,7 @@ try { assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); assert.deepEqual(defaults.subagents, { enabled: false, providers: [] }); + assert.deepEqual(defaults.oauth.allowedResourceUrls, []); assert.deepEqual(defaults.logging, { level: "info", format: "json", @@ -67,6 +68,7 @@ try { accessTokenTtlSeconds: 120, refreshTokenTtlSeconds: 240, scopes: ["devspace", "admin"], + allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com", "example.com"], }, }, env); @@ -99,6 +101,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 e53305268..34fcdfc25 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 e65037884..e122d131a 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,8 @@ 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 (resource && (!recordedResource || !sameResource(resource, recordedResource))) { throw new InvalidGrantError("Invalid resource"); } @@ -230,7 +236,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 +266,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 +348,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 225f9fdf5..f1c2a33bd 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 { @@ -188,6 +190,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,16 +208,20 @@ 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(); @@ -219,18 +230,24 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi 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); await assert.rejects( - secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], mcpUrl), + secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..5f92b5cdd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,7 +8,7 @@ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelconte import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; import { registerAppResource, registerAppTool, @@ -842,7 +842,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,