feat: migrate OAuth public endpoints to MCP SDK auth handlers

Fixes issue #959 — two bugs causing ChatGPT's custom MCP connector to fail:

1. RFC 9728 path-based PRM: ChatGPT requests
   /.well-known/oauth-protected-resource/mcp (path-aware URL per RFC 9728
   §5). The old TREK handler only registered the base path; requests for
   the path variant fell through to the SPA catch-all and returned HTML.
   mcpAuthMetadataRouter registers the path-aware URL automatically.

2. DCR without scope: ChatGPT never sends scope during Dynamic Client
   Registration (RFC 7591 makes it optional). The old handler returned
   400 for missing scope. clientRegistrationHandler accepts it;
   trekClientsStore.registerClient defaults to ALL_SCOPES when absent,
   and the user still grants only what they approve at the consent UI
   (scopeSelectable=true for DCR clients is unchanged).

Hybrid approach: SDK handles /.well-known, /oauth/authorize (redirect to
consent SPA), and /oauth/register. TREK keeps its own /oauth/token and
/oauth/revoke because SDK clientAuth does plain-text secret comparison
while TREK uses SHA-256 hashing — incompatible without a full clientAuth
rewrite.

SPA consent page renamed /oauth/authorize → /oauth/consent to avoid
routing conflict with the SDK's backend authorize handler now mounted at
that path. Existing URL paths (/oauth/token etc.) are unchanged so
active Claude.ai connections are unaffected.

Other: lazy-init SDK metadata router so getAppUrl() (DB query) is not
called at createApp() time; path-aware mcpAddonGate so only /.well-known
returns 404 when MCP is disabled (previously a blanket middleware blocked
all routes including static files); /api/oauth mounted before the SDK
middleware chain so SPA-facing routes with their own 403 gates are
reached correctly.
This commit is contained in:
jubnl
2026-05-05 13:01:32 +02:00
parent 69620e7276
commit 86129bbfbc
10 changed files with 380 additions and 153 deletions
+40 -6
View File
@@ -103,12 +103,48 @@ describe('GET /.well-known/oauth-authorization-server', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Issue #959 regression tests
// ─────────────────────────────────────────────────────────────────────────────
describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => {
it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => {
const res = await request(app).get('/.well-known/oauth-protected-resource/mcp');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.resource).toContain('/mcp');
expect(Array.isArray(res.body.authorization_servers)).toBe(true);
});
});
describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => {
it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' });
expect(res.status).toBe(201);
expect(res.body.client_id).toBeDefined();
expect(typeof res.body.scope).toBe('string');
expect(res.body.scope.length).toBeGreaterThan(0);
});
it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' });
expect(res.status).toBe(201);
expect(res.body.scope).toBe('trips:read');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — authorization_code grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — authorization_code grant', () => {
it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => {
it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' });
@@ -116,13 +152,12 @@ describe('POST /oauth/token — authorization_code grant', () => {
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => {
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
isAddonEnabledMock.mockReturnValue(false);
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('mcp_disabled');
expect(res.status).toBe(404);
});
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => {
@@ -211,7 +246,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => {
it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const { verifier, challenge } = makePkce();
@@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['pragma']).toBe('no-cache');
});
});