diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..ecb01a2be 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,35 @@ 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. +### Split OAuth and MCP resource URLs + +By default, OAuth tokens are accepted for the MCP resource derived from +`server.publicBaseUrl`, for example `https://devspace.example.com/mcp`. + +Some deployments intentionally use a different externally visible MCP resource. +OpenAI Secure MCP Tunnel is one example: DevSpace's browser-facing OAuth server +can stay at `https://devspace.example.com` while ChatGPT reaches MCP through an +OpenAI-hosted tunnel resource. + +Add those MCP resource URLs explicitly: + +```bash +devspace config set oauth.allowedResourceUrls https://api.openai.com/v1/mcp/tunnel_... +``` + +or configure more than one: + +```bash +devspace config set oauth.allowedResourceUrls \ + https://api.openai.com/v1/mcp/tunnel_... \ + https://gateway.example.com/devspace/mcp +``` + +`server.publicBaseUrl` remains the public DevSpace/OAuth URL and its `/mcp` +resource remains accepted. `oauth.allowedResourceUrls` only adds explicitly +trusted MCP resource identities; use the exact tunnel or gateway resource path +rather than a broad gateway origin. + ## Tool modes and UI `tools.mode` accepts two values: diff --git a/docs/gotchas.md b/docs/gotchas.md index 5f6288678..b8aac2a16 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -81,6 +81,24 @@ For a stable URL: npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com ``` +## OAuth Resource Rejected Behind A Secure Tunnel + +If OAuth is public at one origin but ChatGPT reaches MCP through another resource, +DevSpace can reject the OAuth request with `Invalid or missing OAuth resource` or +later return `401 Unauthorized`. + +Keep `publicBaseUrl` on the public DevSpace/OAuth origin and add the exact external +MCP resource: + +```bash +npx @waishnav/devspace config set oauth.allowedResourceUrls \ + https://api.openai.com/v1/mcp/tunnel_... +``` + +This is the expected setup for OpenAI Secure MCP Tunnel and similar split-origin +gateways. Do not configure only `https://api.openai.com`; use the full resource +path assigned to your tunnel. + ## Host Header Or 403 Problems DevSpace derives allowed hosts from the configured public URL. diff --git a/docs/security.md b/docs/security.md index 69bbc1303..a326260de 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,8 +51,8 @@ DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" ## Public URL And Host Allowlist -DevSpace needs `server.publicBaseUrl` in `config.jsonc` so MCP clients can -discover OAuth metadata and connect to the correct resource. +DevSpace needs `server.publicBaseUrl` in `config.jsonc` for its public OAuth and +direct MCP identity. The value should be the origin only: @@ -62,6 +62,11 @@ https://your-tunnel-host.example.com Do not include `/mcp` in `server.publicBaseUrl`. +When MCP traffic reaches DevSpace through a different external resource, keep +`server.publicBaseUrl` pointed at the browser-reachable DevSpace/OAuth origin and +add the exact MCP resource to `oauth.allowedResourceUrls`. DevSpace continues to +accept its normal `${publicBaseUrl}/mcp` resource as well. + By default, DevSpace derives allowed Host headers from the local host and public URL. Put `"*"` in `server.allowedHosts` only for intentional local debugging. @@ -73,6 +78,12 @@ DevSpace does not manage tunnels. Your tunnel or reverse proxy should point to: http://127.0.0.1:7676 ``` +OpenAI Secure MCP Tunnel is different from a normal public reverse proxy: MCP +traffic can stay private and flow through `tunnel-client`, while DevSpace's OAuth +authorization endpoint remains directly browser-reachable. Configure the exact +OpenAI tunnel MCP resource in `oauth.allowedResourceUrls`; do not allowlist a +whole gateway domain. + Prefer adding Cloudflare Access, Tailscale identity controls, or equivalent protection in front of public tunnels. DevSpace OAuth still protects the MCP endpoint, but the tunnel URL should not be treated as a secret. diff --git a/docs/setup.md b/docs/setup.md index e5e76d8f9..c39cb479d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -89,6 +89,22 @@ Configure the MCP client with the full MCP endpoint: https://your-tunnel-host.example.com/mcp ``` +### OpenAI Secure MCP Tunnel + +For OpenAI Secure MCP Tunnel, keep `server.publicBaseUrl` set to the public HTTPS +origin that exposes DevSpace's OAuth endpoints. Point `tunnel-client` at the +private DevSpace MCP endpoint, usually `http://127.0.0.1:7676/mcp`, and add the +exact tunnel-facing MCP resource that ChatGPT uses: + +```bash +npx @waishnav/devspace config set oauth.allowedResourceUrls \ + https://api.openai.com/v1/mcp/tunnel_... +``` + +Use the exact resource URL from your OpenAI tunnel setup. DevSpace validates that +resource during authorization, token exchange and refresh, and every `/mcp` +bearer-token request. + A Coding Agents-only setup skips this section. ## Start The Server 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/cli.test.ts b/src/cli.test.ts index 27c91da40..a5961f47b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -31,6 +31,38 @@ for (const flag of ["-v", "--version"]) { assert.equal(output, packageJson.version); } +const configRoot = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); +try { + const configEnv = writeTestDevspaceConfig(join(configRoot, ".devspace")); + const firstResource = "https://api.openai.com/v1/mcp/tunnel_first"; + const secondResource = "https://gateway.example.com/devspace/mcp"; + + execFileSync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "config", + "set", + "oauth.allowedResourceUrls", + firstResource, + secondResource, + ], + { env: { ...process.env, ...configEnv } }, + ); + assert.deepEqual(loadConfig(configEnv).oauth.allowedResourceUrls, [firstResource, secondResource]); + + execFileSync( + "node", + ["--import", "tsx", "src/cli.ts", "config", "set", "oauth.allowedResourceUrls", "null"], + { env: { ...process.env, ...configEnv } }, + ); + assert.deepEqual(loadConfig(configEnv).oauth.allowedResourceUrls, []); +} finally { + rmSync(configRoot, { recursive: true, force: true }); +} + const root = mkdtempSync(join(tmpdir(), "devspace-cli-agents-test-")); try { const configDir = join(root, ".devspace"); diff --git a/src/cli.ts b/src/cli.ts index b521556a3..091435726 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -192,18 +192,19 @@ async function runInit({ force }: { force: boolean }): Promise { if (useChatGpt) { prompts.note( [ - `Point your HTTPS tunnel or reverse proxy to http://127.0.0.1:${port}.`, - "Paste its public URL below.", + `Expose DevSpace's OAuth endpoints from http://127.0.0.1:${port} over HTTPS.`, + "For a normal tunnel or reverse proxy, paste that public URL below.", + "Secure MCP Tunnel users can configure its MCP resource URL separately after setup.", "", - "Example: https://your-tunnel-host.example.com", + "Example: https://devspace.example.com", ].join("\n"), "Connect ChatGPT", ); publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ message: files.config.server.publicBaseUrl - ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}` - : "What public URL will ChatGPT connect to?", - placeholder: files.config.server.publicBaseUrl ?? "https://your-tunnel-host.example.com", + ? `What public URL exposes DevSpace? Press Enter to keep ${files.config.server.publicBaseUrl}` + : "What public URL exposes DevSpace?", + placeholder: files.config.server.publicBaseUrl ?? "https://devspace.example.com", defaultValue: files.config.server.publicBaseUrl ?? "", validate: validateRequiredPublicBaseUrl, })); @@ -257,7 +258,7 @@ async function runInit({ force }: { force: boolean }): Promise { const lines = [ ...(allowedRoots ? [`Project folders: ${allowedRoots.join(", ")}`] : []), `Coding Agents: ${selectedProviders.join(", ")}`, - ...(publicBaseUrl ? [`ChatGPT connection URL: ${publicBaseUrl}/mcp`] : []), + ...(publicBaseUrl ? [`Public DevSpace URL: ${publicBaseUrl}`] : []), ]; prompts.note(lines.join("\n"), "DevSpace is ready"); if (useChatGpt) { @@ -356,6 +357,7 @@ async function runDoctor(): Promise { const config = loadConfig(); console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`); console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); + console.log(`Additional OAuth resources: ${config.oauth.allowedResourceUrls.join(", ") || "none"}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); const providers = buildLocalAgentProviderStatuses( @@ -381,19 +383,26 @@ function runConfigCommand(args: string[]): void { if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } - if (key !== "publicBaseUrl") { - throw new Error("Only `devspace config set publicBaseUrl ` is supported right now."); - } - - const value = rest.join(" ").trim(); - if (!value) { - throw new Error("Missing publicBaseUrl value."); + if (key === "publicBaseUrl") { + const value = rest.join(" ").trim(); + if (!value) throw new Error("Missing publicBaseUrl value."); + setDevspaceConfigValue( + ["server", "publicBaseUrl"], + normalizeOptionalPublicBaseUrl(value), + ); + } else if (key === "oauth.allowedResourceUrls") { + if (rest.length === 0) { + throw new Error("Missing OAuth resource URL. Pass one or more URLs, or `null` to clear them."); + } + const values = rest.length === 1 && ["null", "none"].includes(rest[0]!.toLowerCase()) + ? [] + : rest.map(normalizeOAuthResourceUrl); + setDevspaceConfigValue(["oauth", "allowedResourceUrls"], values); + } else { + throw new Error( + "Supported settings: `publicBaseUrl` and `oauth.allowedResourceUrls`.", + ); } - - setDevspaceConfigValue( - ["server", "publicBaseUrl"], - normalizeOptionalPublicBaseUrl(value), - ); console.log(`Updated ${files.configPath}`); } @@ -409,6 +418,7 @@ function printHelp(): void { " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", + " devspace config set oauth.allowedResourceUrls ", " devspace show-changes [--json]", " devspace agents ls List subagent sessions", " devspace agents run [--model ] [--effort ] ", @@ -692,6 +702,15 @@ function normalizePublicBaseUrl(value: string): string { return parsed.toString().replace(/\/$/, ""); } +function normalizeOAuthResourceUrl(value: string): string { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`OAuth resource URL must use http or https: ${value}`); + } + parsed.hash = ""; + return parsed.href; +} + type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; diff --git a/src/config-schema.ts b/src/config-schema.ts index c30bb3612..719d847b2 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().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 47a39652a..49e293e09 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -22,6 +22,7 @@ try { assert.equal(defaults.uiEnabled, true); assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); + assert.deepEqual(defaults.oauth.allowedResourceUrls, []); assert.deepEqual(defaults.subagents, { enabled: false, providers: [] }); assert.deepEqual(defaults.logging, { level: "info", @@ -67,6 +68,7 @@ try { accessTokenTtlSeconds: 120, refreshTokenTtlSeconds: 240, scopes: ["devspace", "admin"], + allowedResourceUrls: ["https://api.openai.com/v1/mcp/tunnel_example"], 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://api.openai.com/v1/mcp/tunnel_example", + ]); 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..c786c19ec 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[]; } @@ -28,6 +29,28 @@ interface AuthorizationCodeRecord { const CODE_TTL_MS = 5 * 60 * 1000; +export class OAuthResourcePolicy { + private readonly configuredResources: URL[]; + + constructor(resources: Iterable) { + this.configuredResources = Array.from( + new Map( + Array.from(resources, (resource) => { + const normalized = resourceUrlFromServerUrl(resource); + return [normalized.href, normalized] as const; + }), + ).values(), + ); + } + + allows(requestedResource: URL | undefined): boolean { + if (!requestedResource) return false; + return this.configuredResources.some((configuredResource) => + checkResourceAllowed({ requestedResource, configuredResource }) + ); + } +} + function randomToken(): string { return randomBytes(32).toString("base64url"); } @@ -115,14 +138,12 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { readonly clientsStore: OAuthRegisteredClientsStore; private readonly codes = new Map(); private readonly oauthStore: SqliteOAuthStore; - private readonly resourceServerUrl: URL; constructor( private readonly config: OAuthConfig, - resourceServerUrl: URL, + private readonly resourcePolicy: OAuthResourcePolicy, stateDir: string, ) { - this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); this.oauthStore = new SqliteOAuthStore(stateDir); this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts); } @@ -132,7 +153,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { - if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { + if (!this.resourcePolicy.allows(params.resource)) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { @@ -199,7 +220,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 && !this.resourcePolicy.allows(resource)) { throw new InvalidGrantError("Invalid resource"); } @@ -218,7 +239,7 @@ 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 })) { + if (resource && !this.resourcePolicy.allows(resource)) { throw new InvalidGrantError("Invalid resource"); } diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf5..8f3b9f6ed 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { databasePath, openDatabase } from "./db/client.js"; -import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { OAuthResourcePolicy, SingleUserOAuthProvider } from "./oauth-provider.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); @@ -14,9 +14,12 @@ const oauthConfig = { accessTokenTtlSeconds: 3600, refreshTokenTtlSeconds: 2592000, scopes: ["devspace"], + allowedResourceUrls: [], allowedRedirectHosts: ["chatgpt.com"], }; const mcpUrl = new URL("https://agent.example.com/mcp"); +const tunnelMcpUrl = new URL("https://api.openai.com/v1/mcp/tunnel_example"); +const resourcePolicy = new OAuthResourcePolicy([mcpUrl, tunnelMcpUrl]); const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { @@ -24,11 +27,21 @@ try { testPersistenceAndTokenHashing(join(root, "persistence")); testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); + testResourcePolicy(); await testProviderRestartRotationAndRevocation(join(root, "provider")); } finally { await rm(root, { recursive: true, force: true }); } +function testResourcePolicy(): void { + assert.equal(resourcePolicy.allows(mcpUrl), true); + assert.equal(resourcePolicy.allows(tunnelMcpUrl), true); + assert.equal(resourcePolicy.allows(new URL(`${tunnelMcpUrl.href}/session`)), true); + assert.equal(resourcePolicy.allows(new URL("https://api.openai.com/v1/mcp/other_tunnel")), false); + assert.equal(resourcePolicy.allows(new URL("https://untrusted.example.com/mcp")), false); + assert.equal(resourcePolicy.allows(undefined), false); +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { @@ -187,7 +200,7 @@ function testTransactionalTokenRotation(stateDir: string): void { } async function testProviderRestartRotationAndRevocation(stateDir: string): Promise { - const firstProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); + const firstProvider = new SingleUserOAuthProvider(oauthConfig, resourcePolicy, stateDir); const client = await firstProvider.clientsStore.registerClient?.({ redirect_uris: [redirectUri], client_name: "ChatGPT", @@ -201,7 +214,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi redirectUri, codeChallenge: "challenge", scopes: ["devspace"], - resource: mcpUrl, + resource: tunnelMcpUrl, }, expiresAtMs: Date.now() + 60_000, }); @@ -210,27 +223,28 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi code, undefined, redirectUri, - mcpUrl, + tunnelMcpUrl, ); assert.ok(issued.refresh_token); firstProvider.close(); - const secondProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); + const secondProvider = new SingleUserOAuthProvider(oauthConfig, resourcePolicy, stateDir); try { const verified = await secondProvider.verifyAccessToken(issued.access_token); assert.equal(verified.clientId, client.client_id); + assert.equal(verified.resource?.href, tunnelMcpUrl.href); const refreshed = await secondProvider.exchangeRefreshToken( client, issued.refresh_token, ["devspace"], - mcpUrl, + tunnelMcpUrl, ); 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"], tunnelMcpUrl), InvalidGrantError, ); diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..ee2520c9f 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, @@ -33,7 +33,7 @@ import { sessionIdPrefix, } from "./logger.js"; import { readFileTool } from "./pi-tools.js"; -import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { OAuthResourcePolicy, SingleUserOAuthProvider } from "./oauth-provider.js"; import { McpSessionRegistry, type McpSessionCloseResult, @@ -722,7 +722,11 @@ export function createServer( const transports = new McpSessionRegistry(); const mcpUrl = new URL("/mcp", config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); - const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); + const oauthResourcePolicy = new OAuthResourcePolicy([ + resourceServerUrl, + ...config.oauth.allowedResourceUrls, + ]); + const oauthProvider = new SingleUserOAuthProvider(config.oauth, oauthResourcePolicy, config.stateDir); const bearerAuth = requireBearerAuth({ verifier: oauthProvider, requiredScopes: [config.oauth.scopes[0] ?? "devspace"], @@ -842,7 +846,7 @@ export function createServer( }); if (res.headersSent) return; - if (!req.auth?.resource || !checkResourceAllowed({ requestedResource: req.auth.resource, configuredResource: resourceServerUrl })) { + if (!oauthResourcePolicy.allows(req.auth?.resource)) { logEvent(config.logging, "warn", "auth_denied", { requestId, method: req.method,