Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}
Expand All @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Tool modes and UI

`tools.mode` accepts two values:
Expand Down
8 changes: 8 additions & 0 deletions schema/v1/devspace.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@
"minLength": 1
}
},
"allowedResourceUrls": {
"default": [],
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
},
"allowedRedirectHosts": {
"default": [
"chatgpt.com",
Expand Down
1 change: 1 addition & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()]),
Expand Down
25 changes: 21 additions & 4 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface OAuthConfig {
accessTokenTtlSeconds: number;
refreshTokenTtlSeconds: number;
scopes: string[];
allowedResourceUrls: string[];
allowedRedirectHosts: string[];
}

Expand Down Expand Up @@ -116,13 +117,17 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
private readonly codes = new Map<string, AuthorizationCodeRecord>();
private readonly oauthStore: SqliteOAuthStore;
private readonly resourceServerUrl: URL;
private readonly allowedResourceUrls: Set<string>;

constructor(
private readonly config: OAuthConfig,
resourceServerUrl: URL,
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);
}
Expand All @@ -132,7 +137,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
params: AuthorizationParams,
res: Response,
): Promise<void> {
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)) {
Expand Down Expand Up @@ -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");
}

Expand All @@ -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");
}

Expand All @@ -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,
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
25 changes: 21 additions & 4 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -188,6 +190,11 @@ function testTransactionalTokenRotation(stateDir: string): void {

async function testProviderRestartRotationAndRevocation(stateDir: string): Promise<void> {
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",
Expand All @@ -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();
Expand All @@ -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,
);

Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading