Skip to content
Merged
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
34 changes: 34 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,40 @@ export async function handleApplication(scope: Scope): Promise<void> {
'token audience are not derived from the client-controlled Host header.'
);
}
// Only validate the issuer when MCP is enabled — `enabled: false` is a master
// switch that disables the whole feature, so a stale/placeholder issuer left in
// config must not block startup (and the value is never used downstream when off).
if (mcpConfig?.enabled && mcpConfig.issuer) {
// Validate that the issuer is an absolute http(s) origin.
// resolveIssuer() strips trailing slashes and the value is used verbatim in
// JWT `iss` claims and as a prefix for every endpoint URL (e.g.
// `${issuer}/oauth/mcp/authorize`). A schemeless or path-bearing value
// produces malformed/unparseable URLs downstream — reject it at startup.
// Parse once and check every constraint together: a valid origin has an
// http(s) scheme and nothing beyond an optional trailing slash (no path,
// query, or fragment).
let validOrigin = false;
try {
const parsedIssuer = new URL(mcpConfig.issuer);
Comment thread
heskew marked this conversation as resolved.
const pathWithoutTrailingSlash = parsedIssuer.pathname.replace(/\/+$/, '');
validOrigin =
(parsedIssuer.protocol === 'http:' || parsedIssuer.protocol === 'https:') &&
pathWithoutTrailingSlash === '' &&
parsedIssuer.search === '' &&
parsedIssuer.hash === '' &&
// Embedded credentials (user:pass@host) are not part of an origin and
// would leak into `iss` / endpoint URLs — reject them.
parsedIssuer.username === '' &&
parsedIssuer.password === '';
} catch {
// Unparseable URL — validOrigin stays false.
}
if (!validOrigin) {
throw new Error(
`mcp.issuer must be an absolute http(s) origin like "https://your-host" (got: "${mcpConfig.issuer}")`
);
}
}

// Re-initialize providers from new configuration
// Clear existing providers and repopulate (don't reassign to preserve closure reference)
Expand Down
47 changes: 46 additions & 1 deletion test/options-watcher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,63 @@ describe('OAuth Plugin Options Watcher', () => {
await assert.rejects(handleApplication(scope), /mcp\.issuer is not set/);
});

it('should start when mcp.enabled and mcp.issuer is pinned', async () => {
it('should start when mcp.enabled and mcp.issuer is a valid https origin', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://app.example.com' };
await handleApplication(scope);
assert.ok(resources.oauth, 'OAuth resource should be registered');
});

it('should start when mcp.issuer is an http origin (localhost)', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'http://localhost:9926' };
await handleApplication(scope);
assert.ok(resources.oauth, 'OAuth resource should be registered');
});

it('should start when mcp.issuer has a trailing slash (tolerated)', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://app.example.com/' };
await handleApplication(scope);
assert.ok(resources.oauth, 'OAuth resource should be registered');
});

it('should fail to start when mcp.issuer is schemeless', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'as.example.com' };
await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);
});

it('should fail to start when mcp.issuer includes a path', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://app.example.com/base' };
await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);
});

it('should fail to start when mcp.issuer includes a query string', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://app.example.com?q=1' };
await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);
});

it('should fail to start when mcp.issuer includes a fragment', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://app.example.com#frag' };
await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);
});

it('should fail to start when mcp.issuer embeds credentials', async () => {
scope.options._config.mcp = { enabled: true, issuer: 'https://user:pass@app.example.com' };
await assert.rejects(handleApplication(scope), /mcp\.issuer must be an absolute http\(s\) origin/);
});

it('should start when mcp is disabled regardless of issuer/resource', async () => {
Comment thread
heskew marked this conversation as resolved.
scope.options._config.mcp = { enabled: false };
await handleApplication(scope);
assert.ok(resources.oauth, 'OAuth resource should be registered');
});

it('should start when mcp is disabled even with an invalid issuer (master switch off)', async () => {
// `enabled: false` must not run issuer-format validation — a stale/placeholder
// issuer left in config should not block startup when the feature is off.
scope.options._config.mcp = { enabled: false, issuer: 'as.example.com' };
await handleApplication(scope);
assert.ok(resources.oauth, 'OAuth resource should be registered');
});

it('should update configuration when options change', async () => {
await handleApplication(scope);

Expand Down
Loading