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
77 changes: 73 additions & 4 deletions approuter/lib/mcp-consent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -521,6 +587,8 @@ module.exports = {
idpExchange,
idpRefresh,
resolveBaseUrl,
resolveIasClientId,
handleRegister,
verifyPkce,
sealState,
openState,
Expand All @@ -530,5 +598,6 @@ module.exports = {
CONSENT_PATH,
CALLBACK_PATH,
TOKEN_PATH,
REGISTER_PATH,
SERVICE_NAME,
}
4 changes: 4 additions & 0 deletions approuter/lib/well-known-oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
7 changes: 7 additions & 0 deletions deploy/dev.mtaext
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions deploy/prod.mtaext
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 53 additions & 44 deletions docs/end-users/mcp-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
> `<base>/.well-known/oauth-authorization-server`, self-registers, and is handed the correct
> client_id for that environment automatically — just point it at `<base>/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
{
Expand All @@ -186,31 +187,36 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`:
"command": "npx",
"args": [
"-y", "mcp-remote", "<base>/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"
]
}
}
}
```

> **The `client_id` is the same on all environments — the IAS `tutorials-identity` client:**
> **The `client_id` is ENVIRONMENT-SPECIFIC — match it to your `<base>`:**
>
> | Environment | `<base>` | `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 `<base>/.well-known/oauth-authorization-server`; you supply only the `client_id`. After approval, the token is cached and refreshed silently.

Expand All @@ -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":
> "<base>/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
Expand All @@ -255,21 +261,24 @@ npm install -g mcp-remote
"command": "npx",
"args": [
"-y", "mcp-remote", "<base>/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"
]
}
}
}
```

`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 `<base>` (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 `<base>/.well-known/oauth-authorization-server`,
Expand Down
Loading
Loading