diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml
index 704a2d421..8ec4daa15 100644
--- a/.deploy/mta.yaml
+++ b/.deploy/mta.yaml
@@ -160,6 +160,7 @@ modules:
requires:
- name: tutorials-hana
- name: tutorials-xsuaa
+ - name: tutorials-identity # IAS hybrid auth — validates IAS tokens alongside XSUAA (#2593)
- name: tutorials-destination
- name: tutorials-audit-log
- name: tutorials-cloud-logging
@@ -488,6 +489,13 @@ resources:
parameters:
service-name: tutorials-approuter-identity
+ # IAS identity service instance — adopted as existing-service (created out-of-band).
+ # Used as the IAS client for hybrid auth on tutorials-srv (IAS+xsuaa, #2593).
+ - name: tutorials-identity
+ type: org.cloudfoundry.existing-service
+ parameters:
+ service-name: tutorials-identity
+
- name: tutorials-destination
type: org.cloudfoundry.managed-service
parameters:
diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds
index 5a283a3ce..a1147e0ff 100644
--- a/app/admin-annotations.cds
+++ b/app/admin-annotations.cds
@@ -2158,7 +2158,7 @@ annotate AdminService.ChatSettings with @(
embeddingMinScore @Common.Label: 'Min Similarity Score';
a2aEnabled @Common.Label: 'A2A Enabled' @description: 'Master switch for the /a2a endpoint and Agent Card. When off, /a2a returns 503 and the card signals unavailability.';
a2aPublicBaseUrl @Common.Label: 'A2A Public Base URL' @description: 'Base URL advertised in the Agent Card url; blank = auto-detect from platform (VCAP application_uris).';
- a2aTokenUrl @Common.Label: 'A2A Token URL' @description: 'OAuth token endpoint advertised in the Agent Card xsuaa security scheme.';
+ a2aTokenUrl @Common.Label: 'A2A Token URL' @description: 'OAuth token endpoint advertised in the Agent Card ias security scheme.';
};
annotate AdminService.TutorialFeedback with {
diff --git a/approuter/xs-app.json b/approuter/xs-app.json
index 0a429436c..988392ce9 100644
--- a/approuter/xs-app.json
+++ b/approuter/xs-app.json
@@ -585,9 +585,8 @@
"source": "^/a2a/?$",
"target": "/a2a",
"destination": "srv-api",
- "authenticationType": "xsuaa",
- "csrfProtection": false,
- "scope": "$XSAPPNAME.Tutorial.MCP"
+ "authenticationType": "none",
+ "csrfProtection": false
},
{
"source": "^/tutorial-navigator-qa/?(\\?.*)?$",
diff --git a/deploy/dev.mtaext b/deploy/dev.mtaext
index 7338dba47..cbfe0bbe0 100644
--- a/deploy/dev.mtaext
+++ b/deploy/dev.mtaext
@@ -37,6 +37,9 @@ modules:
# redeploy silently dropped — same override-survival reason as the KG
# flag above. srv/lib/github-app-token.js reads this at resolveGithubToken.
USE_GITHUB_APP: 'true'
+ # A2A agent card tokenUrl derivation (srv/lib/runtime-config/a2a-settings.js reads XSUAA_MCP_URL) — #2593
+ XSUAA_MCP_URL: https://atxgsg7zi.accounts.ondemand.com
+ MCP_ISSUER_KIND: ias
# NOTE: KG_PATH_V2_ENABLED moved to DB-driven config (ImsConfig
# flag.kg.pathV2, issue #2060). Enable it for DEV from the Admin UI /
# setFeatureFlag action instead of pinning an env override here.
diff --git a/deploy/prod.mtaext b/deploy/prod.mtaext
index b5999b025..76b04e815 100644
--- a/deploy/prod.mtaext
+++ b/deploy/prod.mtaext
@@ -30,6 +30,9 @@ modules:
# resolveGithubToken (App-first, PAT fallback). Requires the GitHub App
# installed on tutorials-ims with Actions:write + Contents:write.
USE_GITHUB_APP: 'true'
+ # A2A agent card tokenUrl derivation (srv/lib/runtime-config/a2a-settings.js reads XSUAA_MCP_URL) — #2593
+ XSUAA_MCP_URL: https://atxgsg7zi.accounts.ondemand.com
+ MCP_ISSUER_KIND: ias
parameters:
# 2 instances: redundancy floor for prod (survives instance loss + rolling
# deploys) without the downsides of higher counts. Was briefly 4 for the
diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md
index 9f8f1ea25..61592e2ef 100644
--- a/docs/developers/operations/testing-endpoints.md
+++ b/docs/developers/operations/testing-endpoints.md
@@ -180,8 +180,8 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug
| `/api/advocates/:slug/photo[?size=thumb]` | GET | Photo bytes (256-WebP default, 64-WebP with `?size=thumb`) served from HANA `AdvocatePhotos`. ETag is the sha256, `max-age=86400`. 404 when the advocate has no photo or slug is unknown. | None |
| `/feedback/submit` | POST | Tutorial feedback form (rate-limited; submitter IP hashed via `SUBMISSION_SALT_SECRET`) | None |
| `/chat/stream` | POST | Joule chat streaming endpoint (Server-Sent Events) | XSUAA |
-| `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220). Skill via `metadata.skillId`; defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). | XSUAA + `Tutorial.MCP` |
-| `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, xsuaa security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None |
+| `/a2a` | POST | A2A JSON-RPC 2.0 endpoint (`message/send`, `message/stream`, `tasks/get`, `tasks/cancel`) for central Joule consumption (#1220); machine-to-machine. Skill via `metadata.skillId`, defaults to conversational `tutorial-chat`. Enable/config via `/admin-ui/#joule` (ChatSettings `a2aEnabled`). Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) |
+| `/.well-known/agent-card.json` | GET | A2A Agent Card — public discovery document (5 skills, streaming, IAS security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None |
| `/.well-known/a2a-instructions.md` | GET | A2A consumption guide (how to authenticate + call) | None |
| `/.well-known/tutorial-provenance/jwks.json` | GET | Ed25519 JWKS for verifying tutorial provenance JWS tokens (`{ keys: [...] }`). Returns 404 when `PROVENANCE_ENVELOPE_ENABLED` flag is OFF. Anonymous Express route — `@requires` not applicable. Pair: `/content/tutorials/:slug/provenance`. | None |
| `/api/codecheck` | POST | AI code-check spike (issue #171, gated on `ChatSettings.codeCheckEnabled`). Body: `{ tutorialSlug, stepNumber, submittedCode, language? }`. Returns `{ verdict: 'pass'\|'partial'\|'fail', summary, suggestions[], correctAspects[] }`. 503 when flag off; 429 with `Retry-After` on per-user 30/hr or per-(user,slug,step) 5/5min cap. | XSUAA |
diff --git a/docs/end-users/a2a-quickstart.md b/docs/end-users/a2a-quickstart.md
index 355744972..4fc266d5a 100644
--- a/docs/end-users/a2a-quickstart.md
+++ b/docs/end-users/a2a-quickstart.md
@@ -13,10 +13,10 @@ agent: you hand it a natural-language task and it runs its own agentic loop (sea
graph, progress) and returns a completed A2A `Task`.
> **Two audiences, one endpoint.** The Agent Card is **public** — anyone can discover the agent
-> and its skills without a token. Actually *calling* a skill requires an XSUAA bearer with the
-> `Tutorial.MCP` scope via **OAuth2 client-credentials** — a machine-to-machine flow that is
-> provisioned by a BTP admin, not self-service. If you only want to inspect the agent, start at
-> [Public discovery](#public-discovery) and stop there.
+> and its skills without a token. Actually *calling* a skill requires a SAP IAS bearer token via
+> **OAuth2 client-credentials** (machine-to-machine) — provisioned by a BTP admin, not self-service.
+> There is no PKCE, interactive, or self-service PAT path for A2A. If you only want to inspect the
+> agent, start at [Public discovery](#public-discovery) and stop there.
## Available skills
@@ -25,11 +25,11 @@ to use `tutorial-chat`.
| Skill (`skillId`) | Auth | What it does |
|---|---|---|
-| `tutorial-chat` | scope | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop. **Default**; supports streaming via `message/stream`. |
-| `search-tutorials` | scope | Semantic/keyword search over the tutorial catalog. |
-| `user-progress` | scope **+ forwarded user token** | The signed-in developer's tutorial/mission progress. Returns empty results if the end-user's identity is not forwarded. |
-| `knowledge-graph` | scope | Concept expansion and learning-path reasoning over the tutorial knowledge graph. |
-| `tutorial-steps` | scope | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. |
+| `tutorial-chat` | IAS token | Conversational Q&A over tutorials, missions, and learning paths. Runs the full agentic loop. **Default**; supports streaming via `message/stream`. |
+| `search-tutorials` | IAS token | Semantic/keyword search over the tutorial catalog. |
+| `user-progress` | IAS token **+ forwarded user token** | The signed-in developer's tutorial/mission progress. Returns empty results if the end-user's identity is not forwarded. |
+| `knowledge-graph` | IAS token | Concept expansion and learning-path reasoning over the tutorial knowledge graph. |
+| `tutorial-steps` | IAS token | Returns the most relevant tutorial step content so a calling agent can quote exact instructions. |
## Base URLs
@@ -50,7 +50,7 @@ Key fields:
- `url` — the JSON-RPC endpoint (`/a2a`).
- `skills[]` — the five skills above, with `id`, `description`, `tags`, and `examples`.
-- `securitySchemes.xsuaa.flows.clientCredentials.tokenUrl` — the XSUAA token endpoint you
+- `securitySchemes.ias.flows.clientCredentials.tokenUrl` — the SAP IAS token endpoint you
authenticate against (see below).
- `capabilities.streaming` — `true` (SSE via `message/stream`).
- `documentationUrl` — points at `/.well-known/a2a-instructions.md`, the canonical
@@ -60,46 +60,42 @@ Key fields:
## Authentication (internal / partner)
-All `/a2a` calls require an XSUAA bearer carrying the **`Tutorial.MCP`** scope, obtained via
-**OAuth2 client-credentials**. There is no PAT or interactive PKCE path — this is a
-machine-to-machine flow.
+All `/a2a` calls require a SAP IAS access token obtained via **OAuth2 client-credentials**
+(`grant_type=client_credentials`) against the IAS token endpoint
+`https://atxgsg7zi.accounts.ondemand.com/oauth2/token`. There is **no** PKCE, interactive
+browser, or self-service PAT path for A2A — this is a pure machine-to-machine flow.
### 1. Get client credentials
-You need a `client_id` / `client_secret` pair whose XSUAA instance is granted the `Tutorial.MCP`
-scope. There are two realistic paths:
+You need a `client_id` / `client_secret` pair from the A2A IAS client bound to the platform.
+The credentials live in the `tutorials-identity` IAS service binding (the `identity` service
+instance adopted as `existing-service`):
-- **Platform team (same app):** use the tutorial platform's own XSUAA service key, which already
- owns the scope. Read it from the bound instance:
-
- ```bash
- # dev
- cf env tutorials-srv | sed -n '/xsuaa/,/}/p' # → VCAP_SERVICES.xsuaa[0].credentials
- # prod
- cf env tutorials-prod-srv | sed -n '/xsuaa/,/}/p'
- ```
+```bash
+# dev
+cf env tutorials-srv | jq '.VCAP_SERVICES.identity[0].credentials'
+# prod
+cf env tutorials-prod-srv | jq '.VCAP_SERVICES.identity[0].credentials'
+```
- The `credentials` object carries `clientid`, `clientsecret`, and `url` (the XSUAA tenant base;
- append `/oauth/token`). The `clientid` is environment-specific — `sb-tutorials!…` on dev,
- `sb-tutorials-prod!…` on prod (dev and prod share one XSUAA tenant, so prod uses the distinct
- `tutorials-prod` xsappname).
+The `credentials` object carries `clientid` and `clientsecret`. Use these as `` and
+`` in the token exchange below. The token endpoint is always
+`https://atxgsg7zi.accounts.ondemand.com/oauth2/token` (the IAS tenant is `atxgsg7zi`).
-- **A separate consumer (e.g. a standalone central Joule):** the consumer's own XSUAA instance
- must be granted the `Tutorial.MCP` scope as a foreign-authority grant. **This is a BTP-admin
- step** (an `xs-security.json` `granted-apps` / authority grant on the tutorial platform's side,
- matched by the consumer's `xs-security.json`). It is not self-service — open an issue or contact
- the platform team to arrange the grant for your subaccount. The exact grant recipe is
- intentionally not reproduced here; it depends on the consuming app's xsappname.
+If you are integrating a separate consumer (e.g. a standalone central Joule instance), the
+consumer's IAS application must be registered in the same IAS tenant and granted the `openid`
+scope against the platform's IAS client. **This is a BTP-admin step** — contact the platform
+team to arrange the application registration. It is not self-service.
### 2. Exchange for a token
```bash
export TOKEN_URL="$(curl -s /.well-known/agent-card.json \
- | jq -r '.securitySchemes.xsuaa.flows.clientCredentials.tokenUrl')"
+ | jq -r '.securitySchemes.ias.flows.clientCredentials.tokenUrl')"
export TOKEN="$(curl -s "$TOKEN_URL" \
- -u "$CLIENT_ID:$CLIENT_SECRET" \
- -d 'grant_type=client_credentials' | jq -r '.access_token')"
+ -u ":" \
+ -d 'grant_type=client_credentials&scope=openid' | jq -r '.access_token')"
```
For `user-progress`, additionally forward the end-user's identity token (the platform reads the
@@ -174,7 +170,7 @@ not environment variables, and changes take effect within ~5 seconds without a r
- **A2A Enabled** — master switch; off → `POST /a2a` returns 503 and the card signals unavailability.
- **Public Base URL** — the base advertised in the Agent Card `url`; blank auto-detects from
`VCAP_APPLICATION.application_uris`.
-- **OAuth Token URL** — the XSUAA token endpoint advertised in the card's security scheme.
+- **OAuth Token URL** — the SAP IAS token endpoint advertised in the card's `ias` security scheme.
## See also
diff --git a/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md b/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md
new file mode 100644
index 000000000..c07915e71
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md
@@ -0,0 +1,152 @@
+# A2A Agent Auth Migration: XSUAA client-credentials → SAP IAS
+
+**Issue:** [#2593](https://github.com/sap-tutorials/tutorials-ims/issues/2593)
+**Date:** 2026-10-04
+**Status:** Design approved — ready for implementation plan
+**Branch:** `feat/a2a-ias-auth-2593` (worktree: `.claude/worktrees/a2a-ias-auth`)
+
+## Problem
+
+The MCP signed-in tier (`/mcp-auth/*`) was migrated to SAP IAS (OAuth 2.1
+auth-code + PKCE, public client) in PR #2592. The A2A agent (`POST /a2a`) was
+intentionally left on the **old XSUAA client-credentials** flow — confirmed
+still live on prod via its agent-card (`securitySchemes.xsuaa.flows.clientCredentials`,
+scope `Tutorial.MCP`). This spec migrates A2A's token source to IAS.
+
+### Why "mirror MCP exactly" does not apply
+
+A2A is **machine-to-machine** (`POST /a2a`, no browser). The MCP migration
+solved a *user* auth-code + PKCE redirect flow, which A2A has no equivalent of.
+The honest IAS fit for M2M is **OAuth2 client-credentials** (SAP's documented
+technical-user model) — a like-for-like swap of the token *source* (XSUAA → IAS)
+while keeping the client-credentials *grant*. PKCE does not enter the A2A path.
+
+## Decisions (locked)
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Auth model | **IAS client-credentials (secret)** | Lowest-risk, true like-for-like with what Central Joule consumes today; unblocks the XSUAA→IAS cutover. mTLS/X509 (secretless) is a later config swap. |
+| IAS tenant | **`atxgsg7zi`** (`XSUAA_MCP_URL`) | Matches current MCP prod; keeps A2A and MCP on one tenant. (Issue noted a prior broken-federation note on `atxgsg7zi` resolved by switching to `alzmza7li`; user confirmed `atxgsg7zi`.) |
+| Validation location | **In-process in the main `srv`** (A1) | Faithful mirror of how `srv-mcp` validates; keeps A2A in the main MTA. |
+| Hybrid fallback | **`xsuaa: true`** on the IAS auth kind | Non-breaking, reversible cutover: XSUAA tokens keep working during transition. |
+| `tokenUrl` source | **Derive from IAS issuer** (`/oauth2/token`) | One source of truth, cannot drift; `ChatSettings.a2aTokenUrl` DB column becomes an optional override. |
+
+## Current-state wiring (confirmed)
+
+- **A2A route (approuter):** `approuter/xs-app.json:584-591` — `^/a2a/?$` →
+ `srv-api`, `authenticationType:"xsuaa"`, `scope:"$XSAPPNAME.Tutorial.MCP"`.
+- **A2A handler (main MTA):** `srv/server.js:1165` reserves `POST /a2a`;
+ `srv/server.js:2233-2246` binds `makeA2aRouter()` through CAP `contextMw` +
+ `authMw`. Anonymous reject at `srv/lib/a2a/rpc-router.js:43-46` (JSON-RPC
+ `-32001` / HTTP 401), trusting `cds.context.user` from CAP auth.
+- **Agent card builder:** `srv/lib/a2a/agent-card.js:32-52` (pure). Lines 44-47
+ hardcode `securitySchemes.xsuaa` clientCredentials + `security:[{xsuaa:['Tutorial.MCP']}]`.
+ Injected at `srv/server.js:1103-1111` via `resolveA2aSettings()` →
+ `buildAgentCard({baseUrl, tokenUrl, enabled})`.
+- **A2A config:** DB-driven, no env. `srv/lib/runtime-config/a2a-settings.js` →
+ `ChatSettings` singleton (`a2aEnabled`/`a2aPublicBaseUrl`/`a2aTokenUrl`),
+ `SETTINGS_ID = 00000000-0000-0000-0000-00000000c8a7`.
+- **Main srv auth today:** NO `cds.requires.auth` kind — gated purely by the
+ approuter XSUAA route. No in-process JWKS/issuer validator exists in `srv/`.
+- **MCP IAS reference (the template):** `srv-mcp/package.json:28-31`
+ (`auth:{kind:'ias',xsuaa:true}`, `@sap/xssec@^4.13.1`); approuter discovery
+ `approuter/lib/well-known-oauth.js` (IAS branch keyed on `MCP_ISSUER_KIND=ias`,
+ issuer from `XSUAA_MCP_URL`); IAS client `tutorials-identity` bound in
+ `mta-mcp.yaml:74,84-100` (adopted `existing-service`, public/secretless).
+
+## Target architecture (A1)
+
+```text
+A2A consumer (Joule / trusted BTP)
+ → client-credentials exchange against IAS /oauth2/token (client-id + secret)
+ → POST /a2a with Authorization: Bearer
+ → approuter ^/a2a/?$ authenticationType:"none" → srv-api destination
+ → main srv CAP auth {kind:'ias', xsuaa:true} validates IAS bearer (xssec)
+ → cds.context.user populated → rpc-router (anonymous reject unchanged)
+```
+
+The approuter stops minting the token (`xsuaa`→`none`, token-as-credential,
+matching `/mcp-auth`). CAP's xssec IAS strategy validates in-process. `xsuaa:true`
+keeps XSUAA bearers accepted during cutover.
+
+## Changes
+
+### 1. Config / binding (the real migration)
+
+- **`srv` CAP config** — add `cds.requires.auth = { kind: 'ias', xsuaa: true }`
+ (mirror `srv-mcp/package.json:28-31`). `@sap/xssec@^4.13.1` is **already** a
+ main-srv dependency (root `package.json`), so no new dep is needed. The main
+ srv currently declares **no** `cds.requires.auth` kind — this adds the first.
+- **`mta.yaml` source + `.deploy/mta.yaml`** — bind `tutorials-identity` (existing
+ IAS client, `existing-service`) to `srv-api` alongside `tutorials-xsuaa`.
+ Confirm `srv-api` already carries `XSUAA_MCP_URL` / `MCP_ISSUER_KIND` or that
+ the IAS issuer is otherwise resolvable in the main srv at runtime.
+- **`approuter/xs-app.json:584-591`** — `/a2a` route `authenticationType:"xsuaa"`
+ → `"none"`; remove the `scope` line. Destination stays `srv-api`.
+- **IAS tenant** — stays `atxgsg7zi` via existing `XSUAA_MCP_URL`; no new env var.
+
+> **Blast-radius note:** adding an `auth` kind to the main `srv` affects **every**
+> service in the main MTA, not just `/a2a`. `xsuaa:true` keeps XSUAA tokens valid,
+> so XSUAA-gated routes should be unaffected — but this MUST be verified by the
+> full unit suite plus smoke against other main-MTA surfaces (admin UI, content,
+> `/mcp-admin`) before merge. This is the single highest-risk change in the spec.
+
+### 2. Agent card (`srv/lib/a2a/agent-card.js:44-47`)
+
+- Replace `securitySchemes.xsuaa` with an IAS `clientCredentials` scheme keyed
+ `ias`, `tokenUrl` → IAS `/oauth2/token`. Update `security` to
+ `[{ ias: [...] }]` with the appropriate IAS scope(s).
+- Keep the builder **pure** — `tokenUrl` continues to be injected by the caller.
+
+### 3. tokenUrl resolution (`srv/server.js:1103-1111` + `a2a-settings.js`)
+
+- Derive `tokenUrl = /oauth2/token` from the resolved IAS issuer
+ (same source `well-known-oauth.js:resolveIssuer()` uses — `XSUAA_MCP_URL`).
+- `ChatSettings.a2aTokenUrl`, if non-empty, overrides the derived value (admin
+ escape hatch). Document this precedence in `a2a-settings.js`.
+
+### 4. Docs (3 files + 1 new row)
+
+- **`srv/mcp/a2a-instructions.md`** (lines 17-19, 67) — XSUAA → IAS
+ client-credentials; "OAuth Token URL" → IAS token endpoint.
+- **`docs/end-users/a2a-quickstart.md`** (16-17, 53, 63-105, 177) — rewrite the
+ token-exchange walkthrough for IAS client-credentials (client-id/secret against
+ IAS `/oauth2/token`). Keep it M2M — no PKCE/interactive path.
+- **`hugo/content/api-docs/_index.md`** (139-182, esp. 180) — A2A "### Auth"
+ section → IAS. Mirror the IAS phrasing already used for `/mcp-auth`.
+- **`hugo/data/api_endpoints.yaml`** — **add** an `/a2a` row (none exists today);
+ mirror the `/mcp-auth/*` row shape, `auth` → IAS client-credentials. Then
+ `node scripts/check-api-docs-drift.cjs` MUST pass.
+
+## Testing
+
+- **Unit (fast, in-memory):** `agent-card.js` builder test asserting the IAS
+ scheme shape (`securitySchemes.ias`, `clientCredentials`, derived `tokenUrl`),
+ and the tokenUrl-derivation + DB-override precedence in `a2a-settings.js`.
+- **Drift:** `node scripts/check-api-docs-drift.cjs` → OK (row count +1).
+- **Hybrid/smoke** (`npm run test:hybrid`, `test:smoke`, `test:e2e` as applicable):
+ IAS token accepted on `/a2a`; XSUAA token still accepted (hybrid);
+ anonymous still rejected `-32001`/401 with `WWW-Authenticate: Bearer`.
+- **Non-interactive IAS validation against prod tenant** — as #2592 did for the
+ MCP authorize endpoint: confirm the IAS `/oauth2/token` client-credentials
+ exchange succeeds for the A2A client on `atxgsg7zi` (no `invalid_client`).
+- **Line endings:** all touched files remain LF (Windows worktree — verify).
+
+## Coordination / rollout
+
+- **Central Joule & trusted-BTP consumers** — the token *source* changes
+ (XSUAA → IAS `atxgsg7zi`). `xsuaa:true` hybrid means their existing XSUAA
+ tokens keep working through the transition, but they must be told to re-point
+ at the IAS token endpoint before XSUAA is removed. **Flag this in the PR** —
+ do not remove XSUAA fallback until consumers confirm cutover.
+- **Rollback:** revert the approuter route to `xsuaa` + restore the XSUAA
+ security scheme in the card; the hybrid `auth` kind can stay (it accepts both).
+
+## Out of scope
+
+- Option B (IAS mTLS/X509 secretless) and Option C (forwarded user token /
+ on-behalf-of). B is a later config swap enabled by this structure.
+- Central Joule's own token-source change (coordinated, not implemented here).
+- Homepage `/` flip and any non-A2A auth surface.
+- Removing the XSUAA fallback (`xsuaa:false`) — a separate follow-up after
+ consumer cutover is confirmed.
diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md
index f42df1299..c641732e1 100644
--- a/hugo/content/api-docs/_index.md
+++ b/hugo/content/api-docs/_index.md
@@ -177,7 +177,7 @@ curl -X POST /a2a \
### Auth
-Every `/a2a` call needs an XSUAA bearer carrying the `Tutorial.MCP` scope, obtained via **OAuth2 client-credentials** against the `tokenUrl` in the card's `securitySchemes.xsuaa`. This is a machine-to-machine flow — there is **no** self-service PAT/PKCE path like the MCP signed-in tools. The Agent Card itself is public; `user-progress` additionally needs the end-user's identity forwarded.
+Every `/a2a` call needs a SAP IAS access token (tenant `atxgsg7zi`), obtained via OAuth2 client-credentials against the `tokenUrl` in the agent card's `ias` security scheme. A2A is machine-to-machine: there is no interactive PKCE or self-service PAT path.
Full connection walkthrough (public probe, client-credentials, streaming, errors, Joule wiring): [A2A Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/a2a-quickstart.md). Canonical served guide: [`/.well-known/a2a-instructions.md`](https://github.com/sap-tutorials/tutorials-ims/blob/main/srv/mcp/a2a-instructions.md).
diff --git a/hugo/data/api_endpoints.yaml b/hugo/data/api_endpoints.yaml
index 19d8342cc..23441c419 100644
--- a/hugo/data/api_endpoints.yaml
+++ b/hugo/data/api_endpoints.yaml
@@ -132,6 +132,11 @@ sections:
driftMarkers:
- "/pats"
- "mintPAT"
+ - path: "/a2a"
+ auth: "IAS (client-credentials)"
+ purpose: 'A2A agent (JSON-RPC). Machine-to-machine — SAP IAS access token via OAuth2 client-credentials (tenant atxgsg7zi), no interactive/PKCE/PAT path. Agent card at /.well-known/agent-card.json.'
+ driftMarkers:
+ - "/a2a"
feeds:
columns: [path, format, contents]
diff --git a/mta.yaml b/mta.yaml
index 1fedc3002..32e0e1fa2 100644
--- a/mta.yaml
+++ b/mta.yaml
@@ -173,6 +173,7 @@ modules:
requires:
- name: tutorials-hana
- name: tutorials-xsuaa
+ - name: tutorials-identity # IAS hybrid auth — validates IAS tokens alongside XSUAA (#2593)
- name: tutorials-destination
- name: tutorials-mail
- name: tutorials-audit-log
@@ -384,6 +385,13 @@ resources:
service-name: tutorials-xsuaa
path: ./xs-security.json
+ # IAS identity service instance — adopted as existing-service (created out-of-band).
+ # Used as the IAS client for hybrid auth on tutorials-srv (IAS+xsuaa, #2593).
+ - name: tutorials-identity
+ type: org.cloudfoundry.existing-service
+ parameters:
+ service-name: tutorials-identity
+
- name: tutorials-destination
type: org.cloudfoundry.managed-service
parameters:
diff --git a/package.json b/package.json
index c1dff5896..a68daf732 100644
--- a/package.json
+++ b/package.json
@@ -242,6 +242,10 @@
}
},
"requires": {
+ "auth": {
+ "[hybrid]": { "kind": "ias", "xsuaa": true },
+ "[production]": { "kind": "ias", "xsuaa": true }
+ },
"AICore": {
"resourceGroup": "default",
"[hybrid]": {
diff --git a/srv/lib/a2a/agent-card.js b/srv/lib/a2a/agent-card.js
index 368503166..1c4892bb2 100644
--- a/srv/lib/a2a/agent-card.js
+++ b/srv/lib/a2a/agent-card.js
@@ -42,9 +42,9 @@ export function buildAgentCard({ baseUrl, tokenUrl, enabled = true }) {
defaultInputModes: ['text/plain'],
defaultOutputModes: ['text/plain', 'application/json'],
securitySchemes: {
- xsuaa: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: tokenUrl || '', scopes: { 'Tutorial.MCP': 'MCP/A2A protocol access — authenticated tutorial reads/writes' } } } },
+ ias: { type: 'oauth2', flows: { clientCredentials: { tokenUrl: tokenUrl || '', scopes: { openid: 'SAP IAS technical-user access — authenticated tutorial reads/writes' } } } },
},
- security: [{ xsuaa: ['Tutorial.MCP'] }],
+ security: [{ ias: ['openid'] }],
skills: SKILLS,
documentationUrl: `${baseUrl}/.well-known/a2a-instructions.md`,
metadata: { available: enabled },
diff --git a/srv/lib/runtime-config/a2a-settings.js b/srv/lib/runtime-config/a2a-settings.js
index d24f2dc84..7bcef802d 100644
--- a/srv/lib/runtime-config/a2a-settings.js
+++ b/srv/lib/runtime-config/a2a-settings.js
@@ -12,6 +12,11 @@ let _cached = null;
const DEFAULTS = { enabled: true, publicBaseUrl: '', tokenUrl: '' };
+export function deriveIasTokenUrl(issuerBase) {
+ if (!issuerBase || typeof issuerBase !== 'string') return '';
+ return `${issuerBase.replace(/\/+$/, '')}/oauth2/token`;
+}
+
async function readRow() {
try {
const { ChatSettings } = cds.entities('com.sap.developers.ims');
@@ -47,10 +52,11 @@ export async function resolveA2aSettings() {
const baseRaw = pick(row, 'a2aPublicBaseUrl', 'A2APUBLICBASEURL');
const tokenRaw = pick(row, 'a2aTokenUrl', 'A2ATOKENURL');
+ const derivedTokenUrl = deriveIasTokenUrl(process.env.XSUAA_MCP_URL);
_cached = {
enabled: enabledRaw == null ? DEFAULTS.enabled : !!enabledRaw,
publicBaseUrl: baseRaw || DEFAULTS.publicBaseUrl,
- tokenUrl: tokenRaw || DEFAULTS.tokenUrl,
+ tokenUrl: tokenRaw || derivedTokenUrl || DEFAULTS.tokenUrl,
};
_cachedAt = now;
return _cached;
diff --git a/srv/mcp/a2a-instructions.md b/srv/mcp/a2a-instructions.md
index a62f656ed..c74eb0b28 100644
--- a/srv/mcp/a2a-instructions.md
+++ b/srv/mcp/a2a-instructions.md
@@ -14,8 +14,8 @@ It declares five skills: `tutorial-chat` (conversational, streaming), `search-tu
## Authentication
-All `/a2a` calls require an XSUAA JWT carrying the `Tutorial.MCP` scope (OAuth2
-client-credentials against the `tokenUrl` in the card's `securitySchemes.xsuaa`).
+All `/a2a` calls require a SAP IAS access token obtained via OAuth2
+client-credentials against the `tokenUrl` in the card's `securitySchemes.ias`.
`user-progress` additionally needs the end-user's identity forwarded in the token;
without it, it returns empty results.
@@ -64,7 +64,7 @@ panel on the Joule settings page), stored on the `ChatSettings` singleton:
unavailability.
- **Public Base URL** — the base advertised in the Agent Card `url`; leave blank to
auto-detect from the platform (`VCAP_APPLICATION.application_uris`).
-- **OAuth Token URL** — the XSUAA token endpoint advertised in the card's security scheme.
+- **OAuth Token URL** — the SAP IAS token endpoint advertised in the card's `ias` security scheme.
Changes take effect within ~5 seconds (no restart). These are DB-backed settings, not
environment variables.
diff --git a/test/unit/a2a/a2a-settings.test.js b/test/unit/a2a/a2a-settings.test.js
index 54c42842f..110390229 100644
--- a/test/unit/a2a/a2a-settings.test.js
+++ b/test/unit/a2a/a2a-settings.test.js
@@ -12,7 +12,7 @@ vi.mock('@sap/cds', () => ({
// SELECT.one.from(ChatSettings) → resolve `row`
globalThis.SELECT = { one: { from: () => Promise.resolve(row) } };
-import { resolveA2aSettings, _resetA2aSettingsCache } from '../../../srv/lib/runtime-config/a2a-settings.js';
+import { deriveIasTokenUrl, resolveA2aSettings, _resetA2aSettingsCache } from '../../../srv/lib/runtime-config/a2a-settings.js';
describe('resolveA2aSettings', () => {
beforeEach(() => { row = null; _resetA2aSettingsCache(); vi.clearAllMocks(); });
@@ -75,3 +75,18 @@ describe('resolveA2aSettings', () => {
}
});
});
+
+describe('deriveIasTokenUrl', () => {
+ it('appends /oauth2/token to the IAS issuer base', () => {
+ expect(deriveIasTokenUrl('https://atxgsg7zi.accounts.ondemand.com'))
+ .toBe('https://atxgsg7zi.accounts.ondemand.com/oauth2/token');
+ });
+ it('trims a trailing slash on the issuer base', () => {
+ expect(deriveIasTokenUrl('https://atxgsg7zi.accounts.ondemand.com/'))
+ .toBe('https://atxgsg7zi.accounts.ondemand.com/oauth2/token');
+ });
+ it('returns empty string for a blank issuer', () => {
+ expect(deriveIasTokenUrl('')).toBe('');
+ expect(deriveIasTokenUrl(undefined)).toBe('');
+ });
+});
diff --git a/test/unit/a2a/agent-card.test.js b/test/unit/a2a/agent-card.test.js
index 37c2c31e9..5f67776db 100644
--- a/test/unit/a2a/agent-card.test.js
+++ b/test/unit/a2a/agent-card.test.js
@@ -22,9 +22,12 @@ describe('buildAgentCard', () => {
}
});
- it('injects tokenUrl into the xsuaa security scheme', () => {
- expect(card.securitySchemes.xsuaa.flows.clientCredentials.tokenUrl).toBe('https://uaa.example/oauth/token');
- expect(card.security).toEqual([{ xsuaa: ['Tutorial.MCP'] }]);
+ it('injects tokenUrl into the ias client-credentials scheme', () => {
+ expect(card.securitySchemes.ias.type).toBe('oauth2');
+ expect(card.securitySchemes.ias.flows.clientCredentials.tokenUrl)
+ .toBe('https://uaa.example/oauth/token');
+ expect(card.securitySchemes.xsuaa).toBeUndefined();
+ expect(card.security).toEqual([{ ias: ['openid'] }]);
});
it('signals unavailability when disabled but still returns a card', () => {