diff --git a/approuter/lib/mcp-consent.js b/approuter/lib/mcp-consent.js index 67e772640..622d6602e 100644 --- a/approuter/lib/mcp-consent.js +++ b/approuter/lib/mcp-consent.js @@ -57,6 +57,7 @@ const AUTHORIZE_PATH = `${BASE}/authorize` const CONSENT_PATH = `${BASE}/consent` const CALLBACK_PATH = `${BASE}/callback` const TOKEN_PATH = `${BASE}/token` +const REGISTER_PATH = `${BASE}/register` // The service name shown in the consent copy. Substituted into the SAME verbatim // text the other SAP MCP tools use (Tom, 2026-10-06). @@ -72,15 +73,47 @@ function isEnabled() { } // IAS issuer endpoint. Reuse the approuter's already-configured XSUAA_MCP_URL -// (set in mtaext, read by well-known-oauth.js). The client_id is NOT server -// config — it comes from the MCP client's authorize request and is forwarded through -// the sealed state to the IAS leg. This preserves the env-specific + client-supplied -// model (DEV 0b1e8b56…, PROD sb-tutorials-prod!t676072, etc.). +// (set in mtaext, read by well-known-oauth.js). For the mcp-remote bridge path the +// client_id comes from the client's authorize request (forwarded through sealed +// state); DEV and PROD have DISTINCT tutorials-identity clients (DEV 0b1e8b56…, +// PROD 5a7cb5fd…), so it is env-specific. The native-HTTP (DCR) path instead reads +// that env's clientid server-side via resolveIasClientId() below. function iasIssuer() { const v = process.env.XSUAA_MCP_URL return v ? v.replace(/\/+$/, '') : null } +// Resolve the IAS public PKCE client_id that tutorials-srv-mcp validates token +// `aud` against. This MUST be srv-mcp's clientid, not the approuter's own. +// +// The approuter and the MCP server bind DIFFERENT tutorials-identity instances +// (verified DEV 2026-10-11: approuter instance `tutorials-approuter-identity` +// clientid cf2ea5eb…, srv-mcp instance `tutorials-identity` clientid 0b1e8b56…; +// same tenant app_tid but distinct clients). @sap/xssec `kind:ias` checks +// `aud === credentials.clientid` exactly, so handing a DCR client the approuter's +// own clientid produces WrongAudience 401 after a successful login — the exact +// bug DCR was meant to eliminate. The approuter's VCAP identity binding therefore +// CANNOT be trusted as the source. +// +// Source of truth, in order: +// 1. MCP_RESOURCE_CLIENT_ID — explicit, set per-env in the mtaext to srv-mcp's +// clientid (DEV 0b1e8b56…, PROD 5a7cb5fd…). Deterministic; no shared-binding +// assumption. +// 2. VCAP_SERVICES.identity[].credentials.clientid — fallback only (correct ONLY +// if the approuter happens to bind the same instance as srv-mcp). Mirrors the +// raw-VCAP parsing in credstore-secret.js / well-known-oauth.js (the approuter +// deliberately avoids @sap/xsenv). +// Returns null when neither is available. +function resolveIasClientId() { + const explicit = (process.env.MCP_RESOURCE_CLIENT_ID || '').trim() + if (explicit) return explicit + let services + try { services = JSON.parse(process.env.VCAP_SERVICES || '{}') } catch { return null } + const arr = Array.isArray(services.identity) ? services.identity : [] + const binding = arr.find(b => b && b.credentials && b.credentials.clientid) || arr[0] + return (binding && binding.credentials && binding.credentials.clientid) || null +} + // Externally-visible base URL of this approuter, from the inbound request — // honors CF's x-forwarded-proto/host. Same derivation as well-known-oauth.js. function resolveBaseUrl(req) { @@ -416,6 +449,38 @@ async function handleCallback(req, res, baseUrl) { return redirect(res, withQuery(ctx.client_redirect_uri, { code, state: ctx.client_state })) } +// RFC 7591 Dynamic Client Registration — facade. A native-HTTP OAuth MCP client with +// no pre-configured client_id POSTs here to bootstrap. We do NOT create a new IAS +// client; we return THIS environment's existing tutorials-identity public PKCE +// client_id (resolveIasClientId). That id is public (no secret) and is exactly the +// value tutorials-srv-mcp validates token `aud` against, so the client ends up with +// the correct per-env id automatically — eliminating the wrong-env client_id class +// of failure. The request's redirect_uris are echoed back for the client↔proxy leg +// only (validated at /authorize); the proxy↔IAS leg always uses our fixed CALLBACK_PATH, +// so registration input cannot influence it. +async function handleRegister(req, res) { + const rawBody = await readBody(req) + const params = parseForm(req, rawBody) + + const clientId = resolveIasClientId() + if (!clientId) { + console.error('[mcp-consent] /register: no identity binding in VCAP_SERVICES — cannot issue client_id') + return sendJson(res, 500, { error: 'server_error', error_description: 'identity binding unavailable' }) + } + + const redirectUris = Array.isArray(params.redirect_uris) ? params.redirect_uris : [] + // Public PKCE client: no client_secret, no registration_access_token minted. + return sendJson(res, 201, { + client_id: clientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: redirectUris, + ...(params.client_name ? { client_name: String(params.client_name) } : {}), + }) +} + async function handleToken(req, res) { const rawBody = await readBody(req) const params = parseForm(req, rawBody) @@ -509,6 +574,7 @@ function mcpConsentHandler(req, res, next) { if (req.method === 'POST') { if (pathOnly === CONSENT_PATH) return run(handleConsent(req, res, baseUrl)) if (pathOnly === TOKEN_PATH) return run(handleToken(req, res)) + if (pathOnly === REGISTER_PATH) return run(handleRegister(req, res)) } // A /mcp-oauth/* path we don't serve — do not leak to the SPA proxy. @@ -521,6 +587,8 @@ module.exports = { idpExchange, idpRefresh, resolveBaseUrl, + resolveIasClientId, + handleRegister, verifyPkce, sealState, openState, @@ -530,5 +598,6 @@ module.exports = { CONSENT_PATH, CALLBACK_PATH, TOKEN_PATH, + REGISTER_PATH, SERVICE_NAME, } diff --git a/approuter/lib/well-known-oauth.js b/approuter/lib/well-known-oauth.js index aa3e113de..024f8592f 100644 --- a/approuter/lib/well-known-oauth.js +++ b/approuter/lib/well-known-oauth.js @@ -167,6 +167,10 @@ function authorizationServerMetadata(issuer, endpointBase, scope) { issuer, authorization_endpoint: `${issuer}/mcp-oauth/authorize`, token_endpoint: `${issuer}/mcp-oauth/token`, + // RFC 7591 DCR: native-HTTP OAuth clients register here to obtain this env's + // client_id with zero config (facade — returns the bound tutorials-identity + // clientid). Only advertised under the consent-proxy flip. + registration_endpoint: `${issuer}/mcp-oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], diff --git a/deploy/dev.mtaext b/deploy/dev.mtaext index 83c76d810..3db0eb1ed 100644 --- a/deploy/dev.mtaext +++ b/deploy/dev.mtaext @@ -94,6 +94,13 @@ modules: # state to the IAS leg (preserves env-specific DEV/PROD model). Requires the # MCP_CONSENT_STATE_SECRET to be seeded in the Credential Store. MCP_CONSENT_ENABLED: "true" + # Dynamic Client Registration (RFC 7591) must hand native-HTTP OAuth clients + # the clientid that tutorials-srv-mcp validates token `aud` against. The + # approuter binds its OWN identity instance (tutorials-approuter-identity, + # a DIFFERENT clientid than srv-mcp's tutorials-identity), so its VCAP binding + # is the wrong source — using it yields WrongAudience 401 after login. Pin + # srv-mcp's DEV clientid explicitly; resolveIasClientId() prefers this env. + MCP_RESOURCE_CLIENT_ID: "0b1e8b56-5f5d-4ebf-a9e9-28aae2964236" # deploy.yml's $REBUILD_API_KEY. Removed as the third and final rollout # step of PR #871 (issue #903): the approuter now reads REBUILD_API_KEY # exclusively from the BTP Credential Store via diff --git a/deploy/prod.mtaext b/deploy/prod.mtaext index 7e51d0d80..d7028c79f 100644 --- a/deploy/prod.mtaext +++ b/deploy/prod.mtaext @@ -89,6 +89,10 @@ modules: # #2696/#2697). Read via process.env at call time; MUST be `properties:` not # `env:` (MTA drops module-level env). MCP_CONSENT_ENABLED: "true" + # DCR (RFC 7591) must hand native-HTTP OAuth clients srv-mcp's clientid, not + # the approuter's own (distinct identity instances — see dev.mtaext). Pin + # PROD srv-mcp's clientid; resolveIasClientId() prefers this env over VCAP. + MCP_RESOURCE_CLIENT_ID: "5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34" parameters: app-name: tutorials-prod-approuter instances: 2 diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md index a846427b2..33961fd41 100644 --- a/docs/end-users/mcp-quickstart.md +++ b/docs/end-users/mcp-quickstart.md @@ -105,13 +105,12 @@ Joule Work Desktop connects to an MCP server by registering the server's URL. Fo The curated tools then appear to Joule. -> **Authenticated tools (`/mcp-auth/*`) are not reachable from Joule Work Desktop's native flow.** +> **Authenticated tools (`/mcp-auth/*`) work from Joule Work Desktop's native flow.** > Pasting an authenticated URL triggers Joule Work Desktop's OAuth 2.0 handshake, which uses -> **Dynamic Client Registration (RFC 7591)** — it `POST`s to a `/register` endpoint to -> self-register a client. XSUAA does **not** support DCR; OAuth clients must be **pre-registered** -> (the same limitation described for native Claude clients above). So the personalized tools -> (`get_my_tutorials`, `complete_step`, …) can't be used through Joule Work Desktop today — -> stick to the anonymous `/mcp/*` URLs. +> **Dynamic Client Registration (RFC 7591)** — it `POST`s to the `registration_endpoint` to +> self-register. The server implements DCR (facade over the environment's IAS public client), +> so Joule Work Desktop self-registers, shows the Application Access Request consent, and the +> personalized tools (`get_my_tutorials`, `complete_step`, …) become available after sign-in. ## Joule Studio (classic edition) @@ -170,14 +169,16 @@ The server speaks Streamable HTTP. Clients may send `Accept: application/json` o Phase 2 adds authenticated tools under `/mcp-auth/*` — your tutorial progress, events, and personalized recommendations. To use them, sign in with your SAP universal ID via OAuth. -> **If your Claude Desktop build uses native OAuth, it may fail with -> `does not support dynamic client registration`** — the same XSUAA/DCR limitation described -> in the Claude Code section below. XSUAA requires a **pre-registered** client, so clients -> that insist on RFC 7591 self-registration cannot connect directly. If you hit this, bridge -> through **`mcp-remote`** with the pre-registered `client_id` (see the Claude Code section), -> or use a **[PAT](#headless--ci-with-a-personal-access-token)**. +> **Native-HTTP OAuth clients (incl. Claude Desktop) connect with NO client_id.** +> The server supports **RFC 7591 Dynamic Client Registration**: a client that does its own +> OAuth discovers the `registration_endpoint` from +> `/.well-known/oauth-authorization-server`, self-registers, and is handed the correct +> client_id for that environment automatically — just point it at `/mcp-auth/api` and +> approve the browser sign-in. No `--static-oauth-client-info`, no per-environment id to +> look up. Use the `mcp-remote` bridge below **only** for clients that cannot do native HTTP +> OAuth; it needs the explicit, environment-specific `client_id` from the table. -For builds that accept a pre-registered client, bridge through `mcp-remote`: +For clients that need the `mcp-remote` bridge (no native OAuth), supply the environment's `client_id`: ```json { @@ -186,7 +187,7 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`: "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"0b1e8b56-5f5d-4ebf-a9e9-28aae2964236\"}", + "--static-oauth-client-info", "{\"client_id\":\"5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34\"}", "--host", "localhost" ] } @@ -194,23 +195,28 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`: } ``` -> **The `client_id` is the same on all environments — the IAS `tutorials-identity` client:** +> **The `client_id` is ENVIRONMENT-SPECIFIC — match it to your ``:** > > | Environment | `` | `client_id` | > | --- | --- | --- | -> | **Production** | `https://developers.sap.com` | `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` | +> | **Production** | `https://developers.sap.com` | `5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34` | > | **Dev** | your dev route | `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` | > -> Both environments now authenticate against **IAS** (a public OIDC client — -> authorization_code + PKCE S256, no secret). DEV and PROD share **one** IAS tenant -> (`atxgsg7zi`) and **one** `tutorials-identity` app, with both approuter hosts -> registered as redirect URIs — so there is a **single** client UUID for both. The old -> XSUAA ids (`sb-tutorials-prod!…`, `sb-tutorials!…`) are **retired**: since -> `MCP_ISSUER_KIND: ias` landed in both mtaexts (#2593), discovery advertises IAS, and -> IAS rejects an XSUAA `sb-…` id at the authorize endpoint ("client_id … must match the -> configuration") before PKCE is even checked. If the `tutorials-identity` instance is -> recreated, this UUID changes — read it from the IAS admin console (tenant `atxgsg7zi`, -> app `tutorials-identity` → OpenID Connect Configuration) and update both rows. +> Both environments authenticate against **IAS** (a public OIDC client — +> authorization_code + PKCE S256, no secret) in the shared tenant `atxgsg7zi`. But DEV +> and PROD each have their **own** `tutorials-identity` service instance, and each +> instance has a **distinct clientid** — so the ids differ per environment. The +> interactive id_token's `aud` equals the client_id you supply, and the resource +> (`/mcp-auth`) validates it against the clientid bound to that env's `tutorials-srv-mcp` +> — using the wrong env's id fails with a `WrongAudience`/`invalid_token` 401 **after** a +> successful browser login ("server rejected a token it just issued"). The old XSUAA ids +> (`sb-tutorials-prod!…`, `sb-tutorials!…`) are **retired**: since `MCP_ISSUER_KIND: ias` +> landed in both mtaexts (#2593), discovery advertises IAS and an XSUAA `sb-…` id is +> rejected at the authorize endpoint. If a `tutorials-identity` instance is recreated its +> clientid changes — read the current value from the IAS admin console (tenant +> `atxgsg7zi` → app `tutorials-identity` for that space → Client Authentication → Client +> ID), or from `VCAP_SERVICES.identity[].credentials.clientid` on that env's +> `tutorials-srv-mcp`, and update the matching row. On first connection `mcp-remote` opens a browser tab for consent (PKCE, no client secret required). The endpoints are discovered automatically from `/.well-known/oauth-authorization-server`; you supply only the `client_id`. After approval, the token is cached and refreshed silently. @@ -232,17 +238,17 @@ On first connection `mcp-remote` opens a browser tab for consent (PKCE, no clien ## Sign in with Claude Code (OAuth via mcp-remote) -> **Claude Code's *native* `type: http` OAuth does NOT work against this server.** Its -> built-in OAuth client requires **Dynamic Client Registration** (RFC 7591) — it tries to -> `POST` to a `registration_endpoint` to self-register. XSUAA does not support DCR; OAuth -> clients must be **pre-registered**. Pointing Claude Code's native HTTP client at -> `/mcp-auth/api` fails with `SDK auth failed: Incompatible auth server: does not support -> dynamic client registration`. Use **`mcp-remote`** (below), which accepts a pre-registered -> `client_id`, or use a **[PAT](#headless--ci-with-a-personal-access-token)** (simplest for -> Claude Code — no browser flow). +> **Claude Code's *native* `type: http` OAuth now works against this server.** Its built-in +> OAuth client uses **Dynamic Client Registration** (RFC 7591) — it `POST`s to the +> `registration_endpoint` to self-register. The server implements DCR, so native HTTP OAuth +> succeeds with **no** `client_id`: `"sap-developers-auth": { "type": "http", "url": +> "/mcp-auth/api" }`, then approve the browser sign-in. The `mcp-remote` bridge below +> remains available for older clients or headless use; a **[PAT](#headless--ci-with-a-personal-access-token)** +> is still simplest for CI (no browser flow). -Bridge through `mcp-remote`, which performs the OAuth 2.1 authorization-code + PKCE handshake -against the pre-registered public client and forwards the bearer to `/mcp-auth/api`: +For older clients without native OAuth, bridge through `mcp-remote`, which performs the OAuth +2.1 authorization-code + PKCE handshake and forwards the bearer to `/mcp-auth/api` (supply the +environment's `client_id` from the table above): ```bash npm install -g mcp-remote @@ -255,7 +261,7 @@ npm install -g mcp-remote "command": "npx", "args": [ "-y", "mcp-remote", "/mcp-auth/api", - "--static-oauth-client-info", "{\"client_id\":\"0b1e8b56-5f5d-4ebf-a9e9-28aae2964236\"}", + "--static-oauth-client-info", "{\"client_id\":\"5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34\"}", "--host", "localhost" ] } @@ -263,13 +269,16 @@ npm install -g mcp-remote } ``` -`0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` is the **IAS public OIDC client** (`tutorials-identity` -app in the shared IAS tenant `atxgsg7zi`) — a public PKCE client with no secret. The SAME id -serves both DEV and PROD: they share one subaccount, one IAS tenant, and one `tutorials-identity` -app, with each approuter host registered as a redirect URI. The retired XSUAA ids -(`sb-tutorials-prod!…`, `sb-tutorials!…`) no longer work — IAS rejects them at the authorize -endpoint. To confirm the current id, read it from the IAS admin console (tenant `atxgsg7zi`, app -`tutorials-identity` → OpenID Connect Configuration name). +`5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34` (PROD) / `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` (DEV) +is the **IAS public OIDC client** (`tutorials-identity` app) for each environment — a public +PKCE client with no secret, in the shared IAS tenant `atxgsg7zi`. DEV and PROD each have their +**own** `tutorials-identity` service instance with a **distinct clientid**, so you must use the id +matching your `` (see the table above) — the resource validates the token's `aud` against +its own env's bound clientid. The retired XSUAA ids (`sb-tutorials-prod!…`, `sb-tutorials!…`) no +longer work — IAS rejects them at the authorize endpoint. To confirm the current id for an env, +read it from the IAS admin console (tenant `atxgsg7zi`, the `tutorials-identity` app for that space +→ Client Authentication → Client ID) or from `VCAP_SERVICES.identity[].credentials.clientid` on +that env's `tutorials-srv-mcp`. The flow uses PKCE with no client secret. On first run, `mcp-remote` opens your browser for the SAP universal-ID consent flow; after approval the token is cached in `~/.mcp-auth/` and refreshed silently. The server advertises its endpoints at `/.well-known/oauth-authorization-server`, diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md index c641732e1..51944ff76 100644 --- a/hugo/content/api-docs/_index.md +++ b/hugo/content/api-docs/_index.md @@ -107,7 +107,7 @@ Each CDS service is mounted separately under `/mcp/*` over the [Model Context Pr **Signed-in tools** (your progress, recommendations, marking steps done) live behind two authenticated tiers: -- **`/mcp-auth/*`** — OAuth 2.1 authorization-code + PKCE against SAP Identity Authentication Service (IAS), as a **public client** (no secret). Point an MCP client with native HTTP OAuth straight at the mount — the authorization server, scopes, and PKCE method are advertised via [`/.well-known/oauth-protected-resource`](https://developers.sap.com/.well-known/oauth-protected-resource) and [`/.well-known/oauth-authorization-server`](https://developers.sap.com/.well-known/oauth-authorization-server), so no `mcp-remote` bridge is needed. In Claude Code: `"sap-developers-auth": { "type": "http", "url": "https://developers.sap.com/mcp-auth/api" }` — approve, and the browser sign-in completes the flow. +- **`/mcp-auth/*`** — OAuth 2.1 authorization-code + PKCE against SAP Identity Authentication Service (IAS), as a **public client** (no secret). Point an MCP client with native HTTP OAuth straight at the mount — the authorization server, scopes, PKCE method, and **RFC 7591 Dynamic Client Registration** endpoint are advertised via [`/.well-known/oauth-protected-resource`](https://developers.sap.com/.well-known/oauth-protected-resource) and [`/.well-known/oauth-authorization-server`](https://developers.sap.com/.well-known/oauth-authorization-server), so the client self-registers and no `mcp-remote` bridge or pre-configured `client_id` is needed. In Claude Code: `"sap-developers-auth": { "type": "http", "url": "https://developers.sap.com/mcp-auth/api" }` — approve, and the browser sign-in completes the flow. - **`/mcp-pat/*`** — a **Personal Access Token** for headless / CI clients that can't do a browser flow. Mint one at [`/me/tokens/`](/me/tokens/) (self-service; requires the **Tutorials MCP Users** role collection), then send `Authorization: Bearer pat_...`. Scopes: `read` (read tools) or `write` (also allows `complete_step` / `reset_tutorial_progress`). The plaintext token is shown once. Full connection walkthrough (Claude Desktop, Claude Code, native HTTP OAuth, PATs, troubleshooting): [MCP Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/mcp-quickstart.md). Tool + parameter reference: [mcp-server.md](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/developers/reference/mcp-server.md). diff --git a/test/unit/mcp-consent.test.js b/test/unit/mcp-consent.test.js index d1e2ba199..5e8d96814 100644 --- a/test/unit/mcp-consent.test.js +++ b/test/unit/mcp-consent.test.js @@ -59,7 +59,7 @@ function saveEnv(...names) { for (const n of names) SAVED[n] = process.env[n] } function restoreEnv() { for (const [n, v] of Object.entries(SAVED)) { if (v === undefined) delete process.env[n]; else process.env[n] = v } } beforeEach(() => { - saveEnv('MCP_CONSENT_ENABLED', 'MCP_CONSENT_STATE_SECRET', 'XSUAA_MCP_URL') + saveEnv('MCP_CONSENT_ENABLED', 'MCP_CONSENT_STATE_SECRET', 'XSUAA_MCP_URL', 'VCAP_SERVICES', 'MCP_RESOURCE_CLIENT_ID') credstore._resetForTests() vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -488,3 +488,92 @@ describe('unknown /mcp-oauth path', () => { await vi.waitFor(() => expect(res.statusCode).toBe(404)) }) }) + +describe('dynamic client registration (RFC 7591) — /register facade', () => { + const IDENTITY_VCAP = JSON.stringify({ + identity: [{ name: 'tutorials-identity', credentials: { clientid: '5a7cb5fd-prod-clientid', url: 'https://tenant.accounts.ondemand.com' } }], + }) + + // The DCR facade must return srv-mcp's clientid. Default these tests to the + // VCAP-only path (no explicit env) unless a test opts into MCP_RESOURCE_CLIENT_ID. + beforeEach(() => { delete process.env.MCP_RESOURCE_CLIENT_ID }) + + it('returns 201 with this env\'s bound tutorials-identity client_id (no secret)', async () => { + process.env.VCAP_SERVICES = IDENTITY_VCAP + const body = JSON.stringify({ client_name: 'probe', redirect_uris: ['http://127.0.0.1:0/callback'], token_endpoint_auth_method: 'none' }) + const res = mockRes() + consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn()) + await vi.waitFor(() => expect(res.statusCode).toBe(201)) + const reg = res.json() + expect(reg.client_id).toBe('5a7cb5fd-prod-clientid') + expect(reg.token_endpoint_auth_method).toBe('none') + expect(reg.grant_types).toEqual(['authorization_code', 'refresh_token']) + expect(reg.redirect_uris).toEqual(['http://127.0.0.1:0/callback']) + expect(reg.client_name).toBe('probe') + }) + + it('never returns a client_secret or registration_access_token (public client)', async () => { + process.env.VCAP_SERVICES = IDENTITY_VCAP + const body = JSON.stringify({ redirect_uris: ['http://localhost:0/callback'] }) + const res = mockRes() + consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn()) + await vi.waitFor(() => expect(res.statusCode).toBe(201)) + const reg = res.json() + expect(reg.client_secret).toBeUndefined() + expect(reg.registration_access_token).toBeUndefined() + }) + + it('500s server_error when no identity binding is present in VCAP', async () => { + delete process.env.VCAP_SERVICES + const body = JSON.stringify({ redirect_uris: ['http://127.0.0.1:0/callback'] }) + const res = mockRes() + consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn()) + await vi.waitFor(() => expect(res.statusCode).toBe(500)) + expect(res.json().error).toBe('server_error') + }) + + it('resolveIasClientId reads VCAP_SERVICES.identity[].credentials.clientid', () => { + process.env.VCAP_SERVICES = IDENTITY_VCAP + expect(consent.resolveIasClientId()).toBe('5a7cb5fd-prod-clientid') + delete process.env.VCAP_SERVICES + expect(consent.resolveIasClientId()).toBeNull() + }) + + it('MCP_RESOURCE_CLIENT_ID takes precedence over the approuter VCAP binding', () => { + // Reproduces the DEV 2026-10-11 bug: approuter binds a DIFFERENT identity + // instance than srv-mcp. The explicit env must win so /register hands out + // srv-mcp's clientid, not the approuter's own. + process.env.VCAP_SERVICES = JSON.stringify({ + identity: [{ name: 'tutorials-approuter-identity', credentials: { clientid: 'cf2ea5eb-approuter-own' } }], + }) + process.env.MCP_RESOURCE_CLIENT_ID = '0b1e8b56-srv-mcp' + expect(consent.resolveIasClientId()).toBe('0b1e8b56-srv-mcp') + }) + + it('/register returns MCP_RESOURCE_CLIENT_ID even when VCAP has a different clientid', async () => { + process.env.VCAP_SERVICES = JSON.stringify({ + identity: [{ name: 'tutorials-approuter-identity', credentials: { clientid: 'cf2ea5eb-approuter-own' } }], + }) + process.env.MCP_RESOURCE_CLIENT_ID = '0b1e8b56-srv-mcp' + const body = JSON.stringify({ redirect_uris: ['http://127.0.0.1:0/callback'] }) + const res = mockRes() + consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn()) + await vi.waitFor(() => expect(res.statusCode).toBe(201)) + expect(res.json().client_id).toBe('0b1e8b56-srv-mcp') + }) + + it('falls back to VCAP when MCP_RESOURCE_CLIENT_ID is empty/whitespace', () => { + process.env.VCAP_SERVICES = IDENTITY_VCAP + process.env.MCP_RESOURCE_CLIENT_ID = ' ' + expect(consent.resolveIasClientId()).toBe('5a7cb5fd-prod-clientid') + }) + + it('is a no-op (next) at /register when the feature flag is off', () => { + process.env.MCP_CONSENT_ENABLED = 'false' + const res = mockRes() + const next = vi.fn() + consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, '{}', 'application/json'), res, next) + expect(next).toHaveBeenCalledOnce() + expect(res.statusCode).toBeNull() + }) +}) diff --git a/test/unit/well-known-oauth.test.js b/test/unit/well-known-oauth.test.js index 80c852c39..47f6c3331 100644 --- a/test/unit/well-known-oauth.test.js +++ b/test/unit/well-known-oauth.test.js @@ -307,6 +307,7 @@ describe('well-known-oauth: consent-proxy flip (MCP_CONSENT_ENABLED)', () => { expect(m.issuer).toBe(SELF); expect(m.authorization_endpoint).toBe(`${SELF}/mcp-oauth/authorize`); expect(m.token_endpoint).toBe(`${SELF}/mcp-oauth/token`); + expect(m.registration_endpoint).toBe(`${SELF}/mcp-oauth/register`); expect(m.scopes_supported).toEqual(['openid']); expect(m.code_challenge_methods_supported).toContain('S256'); expect(m.token_endpoint_auth_methods_supported).toContain('none'); @@ -316,6 +317,7 @@ describe('well-known-oauth: consent-proxy flip (MCP_CONSENT_ENABLED)', () => { delete process.env.MCP_CONSENT_ENABLED; const m = authorizationServerMetadata('https://developers.sap.com', 'https://t.authentication.eu10-005.hana.ondemand.com', 'tutorials-mcp.Everyone'); + expect(m.registration_endpoint).toBeUndefined(); expect(m.authorization_endpoint).not.toContain('/mcp-oauth/'); });