Skip to content

XSUAA OAuth for MCP-Native Clients

This guide sets up BTP XSUAA authentication so MCP-native clients (Claude Desktop, Cursor, VS Code, MCP Inspector) can authenticate via OAuth when connecting to ARC-1.

Overview

MCP-native clients use RFC 8414 OAuth discovery to find authorization endpoints at the MCP server's URL. ARC-1 proxies the OAuth flow to XSUAA using the MCP SDK's ProxyOAuthServerProvider.

Auth flow: 1. Client discovers OAuth via /.well-known/oauth-authorization-server 2. Client redirects user to ARC-1's /authorize endpoint 3. ARC-1 proxies to XSUAA's login page 4. After login, XSUAA returns authorization code 5. Client exchanges code for token via ARC-1's /token endpoint 6. Client sends Bearer token with MCP requests

Coexistence: XSUAA OAuth coexists with API key and generic OIDC auth (for example Entra ID, Okta, or Keycloak). All configured methods work on the same /mcp endpoint via a chained token verifier.

Prerequisites

  • SAP BTP Cloud Foundry account with XSUAA entitlement
  • CF CLI installed and logged in
  • ARC-1 deployed on BTP CF (see BTP Cloud Foundry deployment)

Step 1: Create XSUAA Service Instance

The xs-security.json file defines scopes, roles, and OAuth configuration:

cf create-service xsuaa application arc1-xsuaa -c xs-security.json

The included xs-security.json defines 7 scopes:

Scope Description Gates
read Read SAP objects, search, navigate, lint, diagnose SAPRead, SAPSearch, SAPNavigate, SAPContext, SAPLint, SAPDiagnose, SAPManage/SAPTransport/SAPGit read actions
write Create / update / delete / activate ABAP objects SAPWrite, SAPActivate, SAPManage package + FLP mutations
data Preview named table contents SAPRead(type=TABLE_CONTENTS)
sql Execute freestyle SQL queries SAPQuery
transports Create / release / delete CTS transports SAPTransport.create/release/delete
git Push / pull / commit via abapGit / gCTS SAPGit.clone/pull/push/commit
admin Implies ALL other scopes at runtime Everything

And 7 pre-defined role collections (defined in mta.yaml, assignable to users in BTP Cockpit):

Role Collection Scopes Use Case
ARC-1 Viewer read Read-only SAP access
ARC-1 Developer read, write, transports, git Full developer (write + CTS + Git)
ARC-1 Data Viewer read, data Read-only + table preview
ARC-1 Viewer + SQL read, data, sql Read-only + table preview + freestyle SQL
ARC-1 Developer + Data read, write, data, transports, git Developer + data preview
ARC-1 Developer + SQL read, write, data, sql, transports, git Developer + data + freestyle SQL
ARC-1 Admin all 7 Administrative access

Multi-space deployments. mta.yaml derives the route host, the XSUAA xsappname, and these role-collection names from the deploy-time ${space} placeholder, so the same mtar can be deployed into several spaces of one subaccount side by side. The collections therefore appear in the cockpit with the space appended — e.g. ARC-1 Viewer (dev) in space dev. Assign users to the collection for your space (Step 3).

Migrating an existing instance onto per-space naming changes its route host and xsappname: update the MCP client URL, re-assign users to the new ARC-1 … (<space>) collections, and set ARC1_DCR_SIGNING_SECRET before the redeploy so cached OAuth client_ids survive the xsappname change.

Updating an existing XSUAA service with cf update-service arc1-xsuaa -c xs-security.json updates the scopes and role templates only. It does not create role collections declared in mta.yaml. Run a full MTA deployment when adopting these collections, then verify in Security → Role Collections that all seven collections exist and contain the expected roles. This matters especially for older deployments: seeing MCPViewer, MCPDataViewer, or MCPSqlUser under Roles does not mean the corresponding assignable role collections already exist.

Want a restricted developer (can write code but cannot transport or push to Git)? Define your own role template in xs-security.json with just [read, write] scopes, redeploy, and assign it — or use SAP_DENY_ACTIONS on the server.

Role collections are only the user-permission gate. Server flags still have to allow the capability: for example, a user in ARC-1 Developer still cannot create transports unless the ARC-1 instance also has SAP_ALLOW_WRITES=true and SAP_ALLOW_TRANSPORT_WRITES=true.

Assign the least-privilege collection

ARC-1 Developer bundles transports + git — assigning it lets that user create/release CTS transports and push/pull via abapGit/gCTS (when the matching server flags are on). For reviewers, assign ARC-1 Viewer (read only); to grant code-write without transports/Git, use the [read, write]-only template above rather than reusing Developer.

See authorization.md for the full three-layer authorization model.

Step 2: Bind Service and Configure

# Bind XSUAA to your app
cf bind-service arc1-mcp-server arc1-xsuaa

# Enable XSUAA auth
cf set-env arc1-mcp-server SAP_XSUAA_AUTH true

# Restage to pick up changes
cf restage arc1-mcp-server

Verify XSUAA is active in the logs:

cf logs arc1-mcp-server --recent | grep XSUAA
# Should show:
# INFO: XSUAA credentials loaded {"xsappname":"arc1-mcp-<space>!t..."}
# INFO: XSUAA OAuth proxy enabled {"xsappname":"arc1-mcp-<space>!t..."}
# INFO: ARC-1 HTTP server started {"auth":"XSUAA OAuth proxy"}

Step 3: Assign Role Collections

  1. Open BTP CockpitSecurityRole Collections
  2. Find the shipped collection for your space — the names carry the space suffix, e.g. "ARC-1 Viewer ()", "ARC-1 Developer ()", … "ARC-1 Admin ()" (see the multi-space note above)
  3. Click the role collection → EditUsers tab
  4. Add your BTP user (email address)
  5. Save

Assign before you hand out the MCP URL. The assignment creates the shadow user, so it works for users who have never logged in — use the Users tab above, or:

btp assign security/role-collection "ARC-1 Admin (<space>)" \
  --subaccount <subaccount-id> --to-user <email> --of-idp <origin-key>

Order matters. If the user logs in first, that failed login leaves a cached XSUAA session behind, and every retry keeps returning invalid_scope after you grant the role — until they clear their browser cookies. A self-service rollout ("here's the URL, try it") produces that order by default, so it hits essentially every user once. See invalid_scope if you are already in that state.

Step 4: Verify OAuth Discovery

curl -s https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/.well-known/oauth-authorization-server | jq .

Expected response:

{
  "issuer": "https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/",
  "authorization_endpoint": "https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/authorize",
  "token_endpoint": "https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/token",
  "scopes_supported": ["read", "write", "data", "sql", "transports", "git", "admin"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

The example above is the single-target /mcp authorization surface. Select the endpoint before configuring a client:

Endpoint Protected-resource scopes ARC-1 advertises Notes
/mcp read, write, data, sql, transports, git, admin Actual grants and application ceilings still prune tools/actions
/<SYSTEM>/<CLIENT>/mcp read, data, sql, admin Mutation-free pinned multi-target route
/multi/mcp read, data, sql, admin Mutation-free aggregate route; SAP calls require target

XSUAA returns only scopes assigned to the user. Advertising the full mutation-free set does not grant Admin, data, or SQL. It lets OAuth clients request a usable token while role collections remain the authorization source. Multi-target routes never advertise mutation scopes and still require read before revealing whether a pinned target exists.

Step 5: Configure MCP Clients

Use /mcp for a single target, a pinned /<SYSTEM>/<CLIENT>/mcp URL for a target-bound conversation, or /multi/mcp when the model must select among several targets. Do not replace one with another merely to recover an OAuth error: endpoint selection is part of the security and tool contract. Multi-target client examples are also available in Multi-System Setup.

Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "arc1-sap": {
      "url": "https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/mcp"
    }
  }
}

Claude Desktop will automatically discover OAuth via /.well-known/oauth-authorization-server and prompt for login.

Cursor

In Cursor settings → MCP Servers, add:

{
  "arc1-sap": {
    "url": "https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/mcp"
  }
}

MCP Inspector

Connect to:

https://arc1-mcp-<space>.cfapps.us10-001.hana.ondemand.com/mcp

The inspector will perform OAuth discovery and redirect to XSUAA login.

Note: MCP Inspector may use http://127.0.0.1:6274 as its callback URL. ARC-1 automatically rewrites this to http://localhost:6274 because XSUAA only allows http://localhost for redirect URIs, never http://127.0.0.1.

Use Manual OAuth mode for the most predictable Copilot Studio interoperability. Ordinary ARC-1 restarts do not lose stateless DCR registrations; Manual mode is preferred because it avoids an extra dynamic-registration round trip that some Copilot Studio configurations do not retry cleanly.

  1. In Copilot Studio, add an MCP server connection
  2. Select Manual OAuth type
  3. Fill in:
  4. Client ID: XSUAA clientid from cf env <app-name> (e.g. sb-arc1-mcp-<space>!t627062)
  5. Client secret: XSUAA clientsecret from cf env <app-name>
  6. Authorization URL: https://<app-route>/authorize
  7. Token URL template: https://<app-route>/token
  8. Refresh URL: https://<app-route>/token
  9. Scopes: for single-target development, request the approved scopes (for example read write); for multi-target, request read data sql admin and let XSUAA return only the scopes assigned to the signed-in user. ARC-1 auto-qualifies names with the XSUAA xsappname.
  10. Save — Copilot Studio generates a redirect URL
  11. ARC-1 automatically accepts the redirect URL (dynamic redirect URI registration for the XSUAA client)

Why Manual mode: Manual mode pins the connection to the permanent XSUAA service-binding clientid, which sidesteps DCR entirely. Dynamic Discovery (DCR) also works — client_ids are now stateless and survive cf restart/cf push/cell evacuation (see Stateless DCR below) — but Copilot Studio adds a /register round-trip on first connect that some configurations don't retry cleanly. Manual mode is the more predictable path.

Redirect URI: Copilot Studio uses https://global.consent.azure-apim.net/redirect/* — this pattern is already in xs-security.json. ARC-1's dynamic redirect URI registration handles the MCP SDK's exact-match requirement automatically.

Stateless DCR

ARC-1 implements RFC 7591 Dynamic Client Registration (DCR) with a stateless design: each issued client_id is an HMAC-signed token that carries its own registration payload (redirect URIs, grant types, etc.). The signing key is derived from the XSUAA clientsecret, so any process with the same service binding can validate any client_id ever issued — no shared store or persistent state is needed.

This means:

  • DCR registrations survive cf restart, cf push, cf restage, cell evacuations, OOM auto-recovery, and multi-instance scale-out — none of these invalidate cached client_ids.
  • The default lifetime is 0 — never expire. There is no per-client revocation at any TTL (a client_id is a stateless HMAC token, not a store row), so a finite TTL only produces periodic invalid_client re-auth outages — and some MCP clients (Eclipse Copilot, Copilot CLI) don't self-heal from it. Configurable via --oauth-dcr-ttl-seconds / ARC1_OAUTH_DCR_TTL_SECONDS; set a positive value to opt into expiry (clamped to [60s, 90d]). Revocation is global, via signing-key rotation (below).
  • Per-client revocation is intentionally not supported. Forced revocation goes through full key rotation (see below) — rotate the DCR signing key (ARC1_DCR_SIGNING_SECRET) or rebind the XSUAA service. (A deeper KDF_LABEL bump, arc1-dcr/v1v2, also revokes everything, but it lives in the @arc-mcp/xsuaa-auth package now, not this repo.)
  • /register, /authorize, /token, /revoke are per-IP rate-limited by default (ARC1_AUTH_RATE_LIMIT=20/min/IP). Closes CodeQL alert js/missing-rate-limiting. Tune via the env var or disable with =0 if an upstream proxy already provides this. See the Rate Limiting Guide.

By default, the DCR signing key derives from the XSUAA clientsecret. This is convenient (no secret to manage) but has a subtle side effect: cf deploy of an MTA recreates the XSUAA service binding, which rotates the clientsecret and therefore invalidates every cached client_id. Users see invalid_client after every redeploy and must re-register their MCP client.

To decouple the two and survive redeploys, set a dedicated signing secret:

SECRET=$(openssl rand -base64 48)
cf set-env arc1-mcp-server ARC1_DCR_SIGNING_SECRET "$SECRET"
cf restage arc1-mcp-server

ARC-1 emits a [warn] to stderr if ARC1_DCR_SIGNING_SECRET is set without SAP_XSUAA_AUTH=true — the secret is only consumed by the XSUAA OAuth proxy path, so this surfaces a misconfiguration where the secret would otherwise be unused.

Properties: - cf set-env env vars survive cf deploy (CF doesn't reset them, and MTA only touches env vars declared in mta.yaml properties) - Re-setting the value (cf set-env with a new secret + cf restage) is the explicit revocation knob — invalidates every client_id issued under the old secret - Falls back to the XSUAA clientsecret when unset, preserving the legacy behavior - Empty or whitespace-only values are treated as unset (with a [warn]), so a misconfigured env var won't crash startup - A signing secret shorter than 16 bytes (128 bits) triggers a soft warning at startup; use openssl rand -base64 48 for the recommended ≥32 bytes

ARC-1 logs the active signing source as dcrSigningSource: 'override' | 'xsuaa' in the startup INFO line for observability — 'override' means the dedicated ARC1_DCR_SIGNING_SECRET is in use, 'xsuaa' means the legacy clientsecret fallback.

Why this is best practice. A client_id issued via RFC 7591 Dynamic Client Registration is only as durable as the key that signs it — in ARC-1's stateless design the signing key is the registration store. Tying that key to a credential that rotates on deploy (the XSUAA clientsecret) turns every redeploy into an unintended key rotation — the same failure mode a web framework hits when its session-signing key (Django SECRET_KEY, Rails secret_key_base) is regenerated per release: all previously-signed artifacts silently become invalid. The fix is the standard one for any signing key — externalize it from the deploy artifact and keep it stable across releases (12-Factor Config), rotating only when you intend to (OWASP Secrets Management Cheat Sheet).

Service-binding rotation

The XSUAA clientsecret is always an upstream OAuth credential. It is also the DCR signing source only while ARC1_DCR_SIGNING_SECRET is unset. In that fallback configuration, rebinding XSUAA invalidates all DCR registrations. With the recommended dedicated signing secret, rebind/rotation does not revoke DCR clients; rotate ARC1_DCR_SIGNING_SECRET deliberately for global DCR revocation.

To rotate the XSUAA binding:

cf unbind-service arc1-mcp-server arc1-xsuaa
cf bind-service   arc1-mcp-server arc1-xsuaa
cf restage        arc1-mcp-server

After this sequence, the DCR effect depends on the active signing source:

  • With dcrSigningSource: 'xsuaa', every previously issued DCR client_id becomes invalid because the effective signing key changed. Clients must register again; cached access/refresh behavior is client- and token-lifetime-dependent.
  • With dcrSigningSource: 'override', local DCR registrations remain valid because the dedicated signing key did not change. Upstream XSUAA credential rotation can still require a fresh OAuth login, but it is not a DCR revocation event.

DCR state is globally invalidated whenever the effective signing key changes: rotate ARC1_DCR_SIGNING_SECRET in dedicated-key deployments, or rotate/rebind the XSUAA credentials while using the fallback. Routine restart, push without rebind, restage, and cell movement preserve DCR state while that key remains stable.

Recovering a stuck client

After a client has been connected for a while it can fail with one of two errors. They look alike but invalidate different things, so they have different fixes:

Error What's stale Cached token still works? Fix
invalid_token / "not a valid XSUAA, OIDC, or API key token" the access token no — it expired restart the client — a cold start re-runs auth
invalid_client / Invalid client_id the DCR registration usually yes — so tool calls keep working clear the cached registration — a restart alone won't, because the client keeps using its still-valid token and never re-registers

Prevent both: for invalid_client, set a stable ARC1_DCR_SIGNING_SECRET + ARC1_OAUTH_DCR_TTL_SECONDS=0 so a redeploy can't rotate the signing key. For invalid_token, the default refresh-token-validity in xs-security.json is 30 days, so idle sessions survive far longer.

Client behaviour varies. Claude Desktop and MCP Inspector usually re-register on their own and rarely surface either error. VS Code, Cursor, and Eclipse GitHub Copilot cache the DCR registration and can stay stuck on invalid_client until you clear it — steps per client below. (Eclipse additionally has no per-server "restart MCP" / re-auth action yet — copilot-for-eclipse#237.)

Eclipse GitHub Copilot

For invalid_token (an expired or long-idle session — you're effectively logged out): quit and reopen Eclipse. On the cold start it re-runs the sign-in — its "… wants to authenticate" dialog appears — and you're back.

For invalid_client (the server's signing key rotated, e.g. an MTA cf deploy): a restart usually won't help — Copilot keeps using its still-valid access token and never re-registers, so the stale client_id only resurfaces on the next forced sign-in. You have to clear its cached registration so it calls /register again. Quit Eclipse, then delete its one cache file:

# macOS / Linux — clears cached MCP logins; you re-authorize each server once
rm ~/.config/github-copilot/copilot-eclipse.db
# Windows (PowerShell)
Remove-Item "$env:LOCALAPPDATA\github-copilot\copilot-eclipse.db"

Citrix / VDI / roaming profiles: %LOCALAPPDATA% is often not the literal C:\Users\<you>\AppData\Local — the profile is redirected into a container, so the file is there under a different path. Resolve the variable in-session instead of guessing (Eclipse closed):

Get-ChildItem $env:USERPROFILE, $env:LOCALAPPDATA, $env:APPDATA -Recurse -Filter copilot-eclipse.db -Force -ErrorAction SilentlyContinue | Select FullName

Reopen Eclipse → use the server → it registers fresh and prompts you to sign in. Deleting the file is low-impact:

  • ✅ The only cost: re-authorize your MCP server(s) once (a browser sign-in each).
  • ❌ It does not sign you out of GitHub Copilot itself — that's a separate auth.db.
  • ❌ It does not touch your code, workspaces, Eclipse preferences, or your MCP server list — only cached MCP auth.

Want to keep your other MCP servers signed in? With the sqlite3 CLI, delete only this server's rows (the cache is keyed by server URL):

sqlite3 ~/.config/github-copilot/copilot-eclipse.db \
  "DELETE FROM state WHERE key LIKE 'dynamicAuthProvider:%your-app.cfapps%';"

Sanity check: a healthy ARC-1 client_id looks like arc1-eyJ2Ijox… (~280+ chars). A short arc1-<8 hex> id predates the stateless store and is always rejected — clear it the same way.

Cursor

Cursor also caches its registration and may not re-register on invalid_client. Reset it by removing the MCP server entry, restarting Cursor, then re-adding it. With the stable signing key set (above), you only ever do this once.

VS Code

VS Code caches the DCR registration in its own secret storage, keyed by the OAuth issuer URL — so for invalid_client (a rotated signing key) signing out, Restart Server, and removing or renaming the server in mcp.json do not clear it (a sign-out drops the access token but keeps the registration; the issuer URL never changes). Clear the registration itself:

  1. Command Palette (Ctrl/Cmd+Shift+P) → "Authentication: Remove Dynamic Authentication Providers".
  2. Tick the ARC-1 entry — there may be several stale ones; remove them all, then OK.
  3. Restart Server (the arc-1-… entry's actions menu) → trigger any tool → VS Code registers a fresh client_id and prompts you to sign in again.

See Manage MCP servers in VS Code for the Accounts-menu auth controls; the stale-credential cleanup is tracked in microsoft/vscode#269379.

Found an easier way to recover an Eclipse, VS Code, or Cursor MCP login? MCP-client behavior is still evolving — please open an issue or PR so these docs can capture the simplest known fix.

Browser-based DCR clients (rare)

The four MCP clients in the section above (Claude Desktop, Cursor, MCP Inspector, Copilot Studio) all run as native processes — they call /register and /authorize over native HTTP, not the browser fetch API, and never trigger CORS. If a browser-based MCP client (custom playground, embedded widget) calls these OAuth endpoints from a different origin, you must add that origin to ARC1_ALLOWED_ORIGINS. See Security headers & CORS for the full configuration.

Audit events

DCR lifecycle is captured in the audit stream alongside tool calls. Three event types fire:

  • oauth_client_registeredinfo: a new client_id was minted; payload includes the issued id, client name, redirect-URI count, and id length (for tracking URL-budget regressions).
  • oauth_client_lookup_failedwarn (or info for expired): a client_id failed to resolve; reason is one of unknown_prefix / malformed / bad_signature / invalid_payload / expired. Useful for spotting forgery / probing attempts.
  • oauth_redirect_uri_registeredinfo: a redirect URI was added at /authorize time to the pre-registered XSUAA default client. Records what XSUAA's wildcard validator already accepted, so the local SDK-side change is auditable.

Events flow through the existing audit sinks (stderr / file / BTP Audit Log Service) — same pipeline used for tool-call audit.

Updating xs-security.json

If you need to add redirect URIs or change scopes:

# Edit xs-security.json
# Then update the service:
cf update-service arc1-xsuaa -c xs-security.json

# Restage the app to pick up changes:
cf restage arc1-mcp-server

Existing bindings and service keys inherit oauth2-configuration changes — no rebind needed.

Calling ARC-1 from another BTP application

The setup above covers a human at an MCP client (Claude, Cursor, Eclipse) logging in through the browser. A different case is another BTP application — an AI assistant backend, a CAP service, a Fiori app — that already has its users logged in via XSUAA and wants to call ARC-1 as them, without sending them through a second OAuth login.

That is a jwt-bearer token exchange: the caller trades its user's JWT for one audienced to ARC-1. xs-security.json enables it:

"grant-types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"]

The exchange runs against ARC-1's own OAuth client, so the grant belongs in ARC-1's descriptor — not the caller's. Create a service key on ARC-1's XSUAA instance and hand its credentials to the consumer:

cf create-service-key arc1-xsuaa consumer-key
cf service-key arc1-xsuaa consumer-key       # → clientid, clientsecret, url

Issue a separate key per consumer. With the default binding-secret credential type each key carries its own secret, distinct from the running app's binding — so a consumer's key can be rotated or revoked without disturbing ARC-1 itself, and it never exposes the app's own credentials (which, unless you set ARC1_DCR_SIGNING_SECRET, also sign the DCR client_ids — see Stable DCR signing key).

Then either let the Destination service do it (no code — recommended):

Destination property Value
Authentication OAuth2JWTBearer
URL https://<arc1-host>/mcp
tokenServiceURL <url from the service key>/oauth/token
clientId / clientSecret from the service key

…or POST the exchange yourself:

POST <xsuaa-url>/oauth/token
Authorization: Basic <arc1-clientid>:<arc1-clientsecret>
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<the user's JWT>

The returned token is audienced to arc1-mcp!t… and carries only the scopes that user's ARC-1 role collections grant (Step 3) — the exchange propagates identity, it never widens authorization. Present it as Authorization: Bearer … on /mcp.

x509 works too: create the key with -c '{"credential-type":"x509"}' and exchange against the certurl host over mTLS. No credential-types declaration is needed in xs-security.json.

SAP's own walkthrough of this flow — same shape, with a generic "Business Logic Application" where ARC-1 sits — is How grant-types keep your application secure, Exercise 3. Note that grant-types itself is absent from SAP's documented oauth2-configuration property table; it is real and broker-honored, just undocumented.

What this does not enable

  • No headless technical user. client_credentials stays off the allowlist deliberately — there is no way to mint an ARC-1 token with no human behind it.
  • Same subaccount only. A caller in another subaccount fails with Unable to map issuer (#434) — the issuer is not trusted there.
  • Users still need role collections. An exchanged token for a user with no ARC-1 collection authenticates but authorizes nothing.

The external arc-mcp/mcp-hub project uses a different wiring — the hub exchanges with its own client plus a granted-apps grant chain — because it fronts several backends. For a single consumer, the service-key route above is simpler and needs no grant chain.

Configuration Reference

Variable Description Default
SAP_XSUAA_AUTH Enable XSUAA OAuth proxy false

XSUAA credentials are automatically loaded from VCAP_SERVICES when the service is bound. No manual credential configuration is needed.

How Auth Coexistence Works

When XSUAA auth is enabled, the chained token verifier tries three methods in order:

  1. XSUAA JWT — validated by @sap/xssec against XSUAA JWKS (offline, cached)
  2. Generic OIDC JWT — validated by jose against OIDC issuer JWKS (if SAP_OIDC_ISSUER is set)
  3. API Key — simple string match against ARC1_API_KEYS entries

The first successful validation wins. This means: - MCP-native clients (Claude Desktop, Cursor, MCP Inspector) use XSUAA OAuth via auto-discovery - Copilot Studio uses XSUAA OAuth via Manual mode (or generic OIDC, such as Entra ID, if configured separately) - API key auth continues to work for testing and Joule Studio

Troubleshooting

"AADSTS50011: Redirect URI mismatch"

The redirect URI used by the MCP client isn't in xs-security.json. Add the URI pattern:

"redirect-uris": [
  "http://localhost:*/**",
  "https://*.cfapps.us10-001.hana.ondemand.com/**"
]
Then run cf update-service arc1-xsuaa -c xs-security.json.

"Token has no expiration time"

API key tokens now include a synthetic expiration (1 year). If you see this error, ensure you're running the latest version of ARC-1.

"XSUAA credentials not found"

Ensure the XSUAA service is bound: cf services should show arc1-xsuaa bound to your app. If not: cf bind-service arc1-mcp-server arc1-xsuaa && cf restage arc1-mcp-server.

"Insufficient scope" / "invalid_scope"

The user doesn't have the required role collection assigned. Go to BTP Cockpit → Security → Role Collections and assign the appropriate collection to the user.

If the collection is already assigned and you still get invalid_scope, it is one of three things, in the order worth checking.

1. Stale XSUAA browser session — the common one. After an administrator assigns a role collection, the browser can still hold the XSUAA SSO session created before the grant. The failed ARC-1 sign-in page therefore includes Role assigned? Refresh access. Use it after the role assignment, wait for the Access refreshed page, then return to the MCP client and connect or retry sign-in. A new identity-provider login may be required.

The action calls XSUAA's documented /logout.do endpoint with ARC-1's bound client_id and a fixed, allowlisted ARC-1 return URL. Callback query parameters never select the logout host or redirect. Standard Cloud Foundry routes are covered by the https://*.hana.ondemand.com/** entry in xs-security.json; if ARC1_PUBLIC_URL uses a custom domain or path, add its /oauth/logged-out URL to oauth2-configuration.redirect-uris before deploying.

The action clears the browser SSO session; it does not revoke access tokens already issued to other sessions. If an MCP client cached an old token, disconnect/reconnect it after refreshing access. On ARC-1 versions without the action, use a private browser window or delete cookies for the XSUAA domain (<identityzone>.authentication.<region>.hana.ondemand.com) and retry. Read the exact domain from the url field under Cockpit → Application → Service Bindings → arc1-xsuaa → Credentials.

Do not use a bare <xsuaa-url>/logout or /logout.do URL. XSUAA requires the application client and an allowlisted return URL for a reliable application logout.

Diagnostic, in cf logs <app-name> --recent:

GET /authorize?...                         302
GET /oauth/callback?error=invalid_scope    400   ← <200ms later

/authorize/oauth/callback?error=invalid_scope in under ~200 ms with no IAS login form in between is a cached session, not a missing role collection — a genuine grant problem costs a full interactive login first. Corollary: if a retry never shows a login form, the cookie is still there.

2. Role collection with no roles. Deleting and recreating an XSUAA service instance orphans its role collections — collections are subaccount-scoped and survive the instance, their roles do not. A later cf deploy will not re-link them, because the role-collections config in mta.yaml only creates collections whose names don't already exist.

Check Cockpit → Security → Role Collections → "ARC-1 Admin ()" → Roles: it must list role MCPAdmin with Application Identifier arc1-mcp-<space>!t<idx>. An empty Roles tab is the tell. Fix: delete the role collection in the cockpit, cf deploy again, re-assign.

3. Wrong IdP origin. If the subaccount has a custom IAS tenant (trust configuration shows sap.custom), role collections must be assigned with the correct IdP origin. Assigning via sap.default when the user logs in via sap.custom will result in invalid_scope. Platform IdP users (origin <tenant>-platform) are for cockpit and CLI access only — a role collection assigned there does nothing for application logon.

"Invalid client_id" (Copilot Studio)

DCR registrations are stateless and survive ordinary restart, push, restage, and scale-out while the signing key stays stable. Check the startup dcrSigningSource, restore the intended ARC1_DCR_SIGNING_SECRET, or re-register the client after an intentional key/binding rotation. Manual OAuth remains the more predictable Copilot Studio path because it avoids the dynamic registration round trip, not because ARC-1 stores registrations in memory.

"Token validation failed: not a valid XSUAA, OIDC, or API key token" (Copilot Studio)

Copilot Studio caches the access token from the initial sign-in. XSUAA tokens expire after 1 hour and Copilot Studio does not always refresh them automatically — the connector keeps sending the expired token, which ARC-1 correctly rejects.

Fix: re-authenticate the connection. In your bot, open TestConnections → ⋮ next to the ARC-1 connection → Authenticate, or delete and re-add the connection from the connector page.

OAuth flow hangs or returns 400

Check that the XSUAA client ID matches. Run cf env <app-name> and look for the clientid in the XSUAA binding credentials.

"Authorization Request Error" / XSUAA login fails

If using MCP Inspector with http://127.0.0.1:6274, XSUAA rejects the redirect URI (only http://localhost is allowed). ARC-1 handles this automatically by rewriting 127.0.0.1localhost.

Architecture

MCP Client (Claude Desktop, Cursor, MCP Inspector)
  ├── GET /.well-known/oauth-authorization-server  ──→  OAuth metadata
  ├── GET /authorize?client_id=...&redirect_uri=... ──→  Proxied to XSUAA login
  ├── POST /token (authorization_code exchange)     ──→  Proxied to XSUAA token endpoint
  └── POST /mcp (Bearer token)
        ├── requireBearerAuth middleware
        │     └── Chained verifier: XSUAA → OIDC → API key
        └── MCP Server (per-request)
              └── ADT Client → SAP System