From 348146910602b68e1757d91cf5b31afb68e938ea Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:34:19 -0400 Subject: [PATCH 01/10] =?UTF-8?q?docs(a2a):=20design=20spec=20for=20XSUAA?= =?UTF-8?q?=E2=86=92IAS=20auth=20migration=20(#2593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-10-04-a2a-ias-auth-migration-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-a2a-ias-auth-migration-design.md 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. From 403b7cb440c531e04646bf6eaaa9a55039aa3011 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 10:52:57 -0400 Subject: [PATCH 02/10] feat(a2a): derive IAS tokenUrl from issuer, DB column override (#2593) --- srv/lib/runtime-config/a2a-settings.js | 8 +++++++- test/unit/a2a/a2a-settings.test.js | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) 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/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(''); + }); +}); From 78b01aa8484a4bfda3879a0e173cdf1eb5624c1d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 11:06:08 -0400 Subject: [PATCH 03/10] feat(a2a): advertise IAS client-credentials in agent card (#2593) --- srv/lib/a2a/agent-card.js | 4 ++-- test/unit/a2a/agent-card.test.js | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) 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/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', () => { From 7422e4b9b0f3cd1e7f609c2f4773874ae8677cd0 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:10:51 -0400 Subject: [PATCH 04/10] feat(a2a): add IAS+xsuaa hybrid auth to main srv, bind IAS client (#2593) --- .deploy/mta.yaml | 8 ++++++++ mta.yaml | 8 ++++++++ package.json | 4 ++++ 3 files changed, 20 insertions(+) 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/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]": { From 5098f1f3a73506c0f3d0c2f9b4b402e1cec9897d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:37:33 -0400 Subject: [PATCH 05/10] feat(a2a): approuter /a2a as token-as-credential (none) for IAS (#2593) --- approuter/xs-app.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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/?(\\?.*)?$", From 18ee749a4f9cfb5c9b82e496c43cfe582d3f7240 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:45:25 -0400 Subject: [PATCH 06/10] docs(a2a): rewrite served guides for IAS client-credentials (#2593) --- docs/end-users/a2a-quickstart.md | 72 +++++++++++++++----------------- hugo/content/api-docs/_index.md | 2 +- srv/mcp/a2a-instructions.md | 6 +-- 3 files changed, 38 insertions(+), 42 deletions(-) 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/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/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. From 6c9bab9548f106cf45e19fa3a5c30f9fa1955953 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:51:48 -0400 Subject: [PATCH 07/10] docs(a2a): add /a2a row to API inventory, fix drift (#2593) --- docs/developers/operations/testing-endpoints.md | 1 + hugo/data/api_endpoints.yaml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index 9f8f1ea25..a16fa8264 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -181,6 +181,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/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` | +| `/a2a` | POST | A2A agent JSON-RPC, machine-to-machine. IAS (client-credentials) — SAP IAS access token via OAuth2 client-credentials (tenant `atxgsg7zi`), no interactive/PKCE/PAT path. 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, xsuaa 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 | 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] From f74bb5a0d1a32e84f4903d0077ef57f62033301d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:54:53 -0400 Subject: [PATCH 08/10] docs(a2a): consolidate /a2a endpoint row to IAS, drop stale XSUAA (#2593) --- docs/developers/operations/testing-endpoints.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index a16fa8264..229c2f3a0 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -180,8 +180,7 @@ 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` | -| `/a2a` | POST | A2A agent JSON-RPC, machine-to-machine. IAS (client-credentials) — SAP IAS access token via OAuth2 client-credentials (tenant `atxgsg7zi`), no interactive/PKCE/PAT path. Agent card at `/.well-known/agent-card.json`. | IAS (client-credentials) | +| `/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, xsuaa 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 | From 109b05c0740339c2f7680352e67647e3f485a4f8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 12:58:11 -0400 Subject: [PATCH 09/10] docs(a2a): fix stale xsuaa agent-card scheme note (#2593) --- docs/developers/operations/testing-endpoints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/operations/testing-endpoints.md b/docs/developers/operations/testing-endpoints.md index 229c2f3a0..61592e2ef 100644 --- a/docs/developers/operations/testing-endpoints.md +++ b/docs/developers/operations/testing-endpoints.md @@ -181,7 +181,7 @@ When `EXPOSE_CAP_UI=true` is set on the CAP srv app, these are accessible throug | `/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); 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, xsuaa security scheme). Base URL + token URL from `/admin-ui/#joule` (ChatSettings). | None | +| `/.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 | From 4ad036bbe36b1b6c566b4ea8fa1950e1294759ef Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Sun, 4 Oct 2026 13:10:02 -0400 Subject: [PATCH 10/10] fix(a2a): set XSUAA_MCP_URL on srv for card tokenUrl + ias scheme desc (#2593) --- app/admin-annotations.cds | 2 +- deploy/dev.mtaext | 3 +++ deploy/prod.mtaext | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) 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/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