Both tenants belong to the same starter system: my429480 is its customizing tenant (client 100, key-user extensibility only, no ABAP) and my431353 is its development tenant (client 080). This package had recorded my429480 as "productive" and ADR-002 had assumed it offered developer extensibility; both were wrong. - ADR-004 withdraws the on-stack RAP route and moves the application to a side-by-side runtime against released OData APIs. ADR-002 marked superseded. - Development tenant my431353 parked: all 19 ZARENA_PLM objects left active, sources kept in docs/handover/rap/ as reference, so the route stays reversible. - Replacement host left open as OQ-11 (key-user extensibility vs CAP side-by-side); recommendation recorded as CAP. No build starts until OQ-11 is closed. - RAP-dependent items V-2, V-3, OQ-5, OQ-8, OQ-9, OQ-10 parked rather than closed. - Tenant references corrected across CLAUDE.md, KNOWLEDGE.md, app/README.md, the RAP handover README, the rap-developer agent and the reentrance-login helper README. No SAP tenant was modified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
10 KiB
Markdown
247 lines
10 KiB
Markdown
# adt-reentrance-login
|
||
|
||
Capture an SAP **ADT reentrance ticket** through a **localhost redirect**, so
|
||
`abap-adt-mcp` can log on to the S/4HANA Cloud development tenant from this
|
||
headless / SSH-only host — without a browser window on the server's desktop.
|
||
|
||
Node 24, standard library only, no dependencies, no install step.
|
||
|
||
> **Parked 2026-09-29.** `my431353` is the starter system's **development**
|
||
> tenant (client 080). I parked that tenant on 2026-09-29 together with the
|
||
> on-stack RAP route, leaving all of its objects active in place. I keep this
|
||
> helper only in case that route is revived — nothing in the current
|
||
> side-by-side architecture calls it. Every `my431353` reference below is the
|
||
> default this tool was built against, not a live target. The working target
|
||
> is the starter system's customizing tenant `my429480` (client 100), which
|
||
> supports key-user extensibility only and needs no ADT logon at all.
|
||
|
||
---
|
||
|
||
## Why this exists
|
||
|
||
`abap-adt-mcp` 2.1.0 can do browser SSO, but only by launching a **server-side**
|
||
Chrome window (`headless: false`) and harvesting cookies over the DevTools
|
||
protocol; there is no localhost redirect in that path, so over multi-hop SSH
|
||
neither the desktop nor a DevTools relay is reachable in practice
|
||
[KNOWLEDGE.md, 2026-09-16].
|
||
|
||
ADT for VS Code does it differently. It opens
|
||
|
||
```
|
||
https://<tenant>/sap/bc/adt/core/http/reentranceticket?redirect-url=http://localhost:<port>/adt/redirect
|
||
```
|
||
|
||
and reads the ticket back out of the redirect
|
||
|
||
```
|
||
http://localhost:<port>/adt/redirect?...&reentrance-ticket=<ticket>
|
||
```
|
||
|
||
(observed 2026-09-16). That half is portable: **your** browser does the SSO, the
|
||
tenant redirects to `localhost`, and a forwarded TCP port puts "localhost" back
|
||
on this host. This script is that listener.
|
||
|
||
---
|
||
|
||
## Run it
|
||
|
||
Three steps, three terminals-worth of context. Values below use the defaults
|
||
(tenant `my431353`, port `42010`, 300 s timeout) — that tenant being the
|
||
development tenant parked on 2026-09-29, kept here as the default this tool
|
||
shipped with.
|
||
|
||
### 1. Start the helper on this host
|
||
|
||
```bash
|
||
node /home/alexz/projects/sap-integrations-mcl/arena-plm/tools/adt-reentrance-login/login.mjs
|
||
```
|
||
|
||
It prints (to stderr) the port to forward and the URL to open, then waits up to
|
||
300 seconds for exactly one redirect.
|
||
|
||
### 2. Forward the port to the machine running your browser
|
||
|
||
**VS Code:** open the **Ports** panel (`Ctrl+Shift+P` → *Ports: Focus on Ports
|
||
View*) → **Forward a Port** → enter `42010`. VS Code forwards it to your local
|
||
`localhost:42010`.
|
||
|
||
**Plain SSH**, from your laptop:
|
||
|
||
```bash
|
||
ssh -L 42010:127.0.0.1:42010 <this-host>
|
||
```
|
||
|
||
The browser must reach **this host's** localhost. Without the forward the tenant
|
||
redirects your browser to its own machine and nothing is captured.
|
||
|
||
### 3. Open the URL in that browser and complete SSO
|
||
|
||
```
|
||
https://my431353.s4hana.cloud.sap/sap/bc/adt/core/http/reentranceticket?redirect-url=http://localhost:42010/adt/redirect
|
||
```
|
||
|
||
That host is the parked development tenant (client 080, parked 2026-09-29);
|
||
point `--tenant` elsewhere if the revived route ever lands on another tenant.
|
||
|
||
(Or capture it programmatically: `node login.mjs --print-url-only`.)
|
||
|
||
After SSO the tab lands on "Ticket captured", the helper writes
|
||
`~/.abap-adt-mcp/ticket.json` (mode 0600) and exits 0.
|
||
|
||
### 4. Make the MCP server pick it up
|
||
|
||
`~/.abap-adt-mcp/ticket-provider.sh` already `cat`s that file, and
|
||
`abap-adt-mcp` caches the login per destination — it only re-runs the provider
|
||
on an explicit `login` call or a `reauthenticate`
|
||
(`dist/index.js:268-292`, `615-625`, read 2026-09-18). So after capturing a
|
||
fresh ticket, call the MCP `login` tool for the destination.
|
||
|
||
---
|
||
|
||
## Options
|
||
|
||
| Flag | Default | Meaning |
|
||
|---|---|---|
|
||
| `--tenant <url>` | `https://my431353.s4hana.cloud.sap` | Tenant base URL. The UI host, not the `-api` host. The default is the development tenant parked on 2026-09-29. |
|
||
| `--port <n>` | `42010` | Loopback port. Bound to `127.0.0.1` **only**; there is no flag to change the bind address. |
|
||
| `--timeout <seconds>` | `300` | Give up and write nothing. |
|
||
| `--out <path>` | `~/.abap-adt-mcp/ticket.json` | Output file, mode 0600 (directory created 0700). |
|
||
| `--client <nnn>` | *not sent* | Adds `sap-client=<nnn>`. See "Which client?" below. |
|
||
| `--allow-host <name>` | — | Additionally accept this `Host` header (repeatable), for tunnelled forwards that are not `localhost`. |
|
||
| `--probe` | off | After capture, test cookie vs header transport (below). |
|
||
| `--print-url-only` | off | Print the logon URL to **stdout** and exit; start no listener. |
|
||
| `--help` | — | Usage. |
|
||
|
||
Exit codes: `0` captured · `2` usage · `3` timeout · `4` cannot bind · `5`
|
||
redirect arrived without a usable ticket.
|
||
|
||
### Which client?
|
||
|
||
Nothing is hard-coded. The client the browser session uses is unconfirmed; the
|
||
published service URL shows `sap-client=080`, but that is the API host's
|
||
service URL, not proof about the UI logon. Run without `--client` first; only
|
||
add `--client 080` if the tenant needs it.
|
||
|
||
---
|
||
|
||
## Probe mode (`--probe`)
|
||
|
||
Open question this settles: SAP documents a **logon ticket** in the `MYSAPSSO2`
|
||
**cookie** and an **assertion ticket** in a `mysapsso2` **header**
|
||
[source: sap-docs sap-help loio 3354605668 and 3361378221, verified
|
||
2026-09-18]. Which one a *reentrance* ticket behaves as is **unknown**, and it
|
||
decides whether `abap-adt-mcp`'s `sso2` mode can work at all — that mode sends
|
||
the value strictly as a **cookie** (`dist/lib/sso2TicketProvider.js`, read
|
||
2026-09-18).
|
||
|
||
With `--probe`, after capture the helper makes two GETs to
|
||
`<tenant>/sap/bc/adt/core/discovery` with `Accept: application/atomsvc+xml` and
|
||
**no other credentials**:
|
||
|
||
- **(a)** `Cookie: MYSAPSSO2=<ticket>`
|
||
- **(b)** `MYSAPSSO2: <ticket>`
|
||
|
||
and reports only the HTTP status of each, whether a `set-cookie` with
|
||
`SAP_SESSIONID` came back, and which succeeded. Bodies are discarded unread. A
|
||
redirect is reported by **host only** (a SAML `Location` can carry request
|
||
material).
|
||
|
||
Read the verdict as:
|
||
|
||
- **cookie worked** → `abap-adt-mcp`'s `sso2` mode works as-is.
|
||
- **only the header worked** → `sso2` mode **cannot** use this ticket; the MCP
|
||
server would need a header transport it does not have.
|
||
- **neither** → expired ticket, wrong client, or wrong mechanism entirely.
|
||
|
||
---
|
||
|
||
## Ticket lifetime
|
||
|
||
- **The lifetime of a reentrance ticket is not verified here.** Treat it as
|
||
short-lived and re-run the helper whenever logon fails. Do not assume the
|
||
8-hour default of a classic SAP logon ticket applies.
|
||
- Whether a reentrance ticket is **single-use** is also unverified. If a
|
||
captured ticket authenticates once and then stops, assume single-use and
|
||
re-capture.
|
||
- Expiry surfaces in the ADT MCP only as **"Your user was logged off."**
|
||
`abap_list_destinations` and `get_object_type_details` still answer, so
|
||
neither proves a live session; `abap_creation-run_validation` is the cheapest
|
||
read-only liveness check [KNOWLEDGE.md, 2026-09-16].
|
||
|
||
---
|
||
|
||
## Security rules
|
||
|
||
These are contracts, not preferences. The ticket is a **live logon credential**
|
||
for the tenant.
|
||
|
||
1. **The ticket is never printed, logged, or put in an error message, argv, or
|
||
an environment variable.** The only place it is written is `--out`, mode
|
||
0600. Validation failures report the *reason* and the parameter *names*
|
||
present — never values, never offending characters or positions.
|
||
2. **stdout stays empty.** Every human-readable message goes to stderr, so
|
||
nothing sensitive can be captured by a `$(...)` around the helper. The single
|
||
exception is `--print-url-only`, which prints the (secret-free) logon URL.
|
||
3. **The listener binds `127.0.0.1` only** and is one-shot. There is no flag to
|
||
bind a wider interface. A `Host` header that is not a loopback name is
|
||
refused (cheap DNS-rebinding guard); allow a specific tunnel name with
|
||
`--allow-host`.
|
||
4. **Never paste the redirect URL** into a chat, issue, log, or file — it
|
||
contains the ticket. Note that the URL also lands in **your browser's
|
||
history**; clear it on a shared machine.
|
||
5. The output file is written with permissions set **before** any ticket byte
|
||
reaches the disk (`open` + `fchmod` + `write`), so a pre-existing
|
||
world-readable file cannot briefly expose it.
|
||
6. Ticket values are validated against the RFC 6265 cookie-octet set copied
|
||
verbatim from `abap-adt-mcp` — a ticket the MCP server would reject is
|
||
rejected here instead of being written to a file that cannot work.
|
||
|
||
### Percent-decoding, and why not `URLSearchParams`
|
||
|
||
The ticket is URL-decoded **exactly once** with `decodeURIComponent` over the
|
||
raw query pair, and never re-encoded.
|
||
|
||
`URLSearchParams` is deliberately **not** used: it applies
|
||
`application/x-www-form-urlencoded` rules and turns a literal `+` into a space.
|
||
Measured on Node v24.15.0, 2026-09-18:
|
||
|
||
```
|
||
'?reentrance-ticket=AB%2BCD+EF%3d'
|
||
searchParams.get(...) -> 'AB+CD EF=' <-- space, corrupt
|
||
decodeURIComponent(raw pair) -> 'AB+CD+EF=' <-- correct
|
||
```
|
||
|
||
A space is not a legal cookie-octet, so a base64 ticket carrying `+` would be
|
||
silently corrupted and then rejected.
|
||
|
||
---
|
||
|
||
## Configuration (handled by the repo owner, documented here for reference)
|
||
|
||
This script does **not** touch `~/.abap-adt-mcp/systems.json` or `.mcp.json`.
|
||
For the record, `abap-adt-mcp` 2.1.0 requires for `authType: "sso2"`
|
||
(`dist/lib/systems.js:73-149`, read 2026-09-18):
|
||
|
||
- `sso2.command` must be an **absolute executable path** (`sso2.args` optional,
|
||
`sso2.timeoutMs` 1000–300000);
|
||
- the system `url` must be **HTTPS** ("so the ticket is never sent in
|
||
plaintext");
|
||
- `insecureTls` is refused in combination with `sso2`;
|
||
- `authType: "ticket"` is accepted as an alias for `sso2`;
|
||
- the server warns at startup that every remote caller of an `sso2` destination
|
||
shares the SAP identity of the user running it — one server instance per
|
||
person.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Cause |
|
||
|---|---|
|
||
| Browser cannot reach `localhost:42010` | Port not forwarded, or forwarded to the wrong host. Re-check step 2. |
|
||
| `port ... is already in use` (exit 4) | Another listener. Use `--port <n>` and forward **that** port. |
|
||
| `Timed out ... Nothing was written.` (exit 3) | SSO never completed, or the redirect went to the wrong machine. |
|
||
| `the redirect carried no reentrance-ticket parameter` (exit 5) | The tenant redirected without a ticket — usually a failed or cancelled SSO. The parameter names it *did* send are listed. |
|
||
| `ticket contains characters outside the RFC 6265 cookie-octet set` (exit 5) | The value is not usable as a `MYSAPSSO2` cookie; `abap-adt-mcp` would reject it too. |
|
||
| `Refused a redirect with Host: ...` | Forward is not a plain localhost forward. Re-run with `--allow-host <that-host>`. |
|