Sync current sources: clean RAP handover names, app rebind, tooling
Working-tree state as of 2026-09-29, uncommitted since the initial "upload current sources" commit: - docs/handover/rap/: renamed the 11 generator-suffixed sources (zz*000) to the clean object names built on the tenant, plus the A2X leg and parameter-entity edits. - app/: manifest, annotations, i18n, mock data and local/mock ui5 configs updated to the live service shape. - PRD.md, CHANGELOG.md, KNOWLEDGE.md: build record through 2026-09-19, including the rejected reentrance-ticket path. - tools/adt-reentrance-login/: retained login helper (new). - docs/v2/zui_arena_change_appr_o4.metadata.xml: captured live $metadata (new). - Dropped a stray 0.3GB-class Eclipse installer that the initial commit had tracked under a URL-mangled filename, and the machine-specific 0M7DEJI.code-workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
(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. |
|
||||
| `--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>`. |
|
||||
Executable
+441
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* adt-reentrance-login — capture an SAP ADT reentrance ticket over a localhost
|
||||
* redirect, so `abap-adt-mcp` can log on to an S/4HANA Cloud tenant from a
|
||||
* headless / SSH-only host.
|
||||
*
|
||||
* ADT for VS Code logs on by opening
|
||||
* https://<tenant>/sap/bc/adt/core/http/reentranceticket?redirect-url=http://localhost:<port>/adt/redirect
|
||||
* and reading the ticket out of the redirect
|
||||
* http://localhost:<port>/adt/redirect?...&reentrance-ticket=<ticket>
|
||||
* (observed 2026-09-16). This script plays the "localhost" half of that dance:
|
||||
* it listens on 127.0.0.1 only, captures exactly one ticket, and writes it as
|
||||
* {"ticket":"..."} to a 0600 file for ~/.abap-adt-mcp/ticket-provider.sh.
|
||||
*
|
||||
* SECURITY CONTRACT (do not relax):
|
||||
* - The ticket is a live logon credential. It is NEVER printed, logged, put
|
||||
* in an error message, passed in argv, or exported to the environment.
|
||||
* The only place it is written is the --out file, mode 0600.
|
||||
* - The listener binds 127.0.0.1 exclusively. There is no flag to change it.
|
||||
* - All human-facing output goes to stderr. stdout stays empty, except in
|
||||
* --print-url-only mode, which prints the (secret-free) logon URL so it can
|
||||
* be captured with $(...).
|
||||
*
|
||||
* Node 24, standard library only, no dependencies.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { chmodSync, closeSync, fchmodSync, mkdirSync, openSync, writeSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const DEFAULT_TENANT = 'https://my431353.s4hana.cloud.sap';
|
||||
const DEFAULT_PORT = 42010;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 300;
|
||||
const DEFAULT_OUT = join(homedir(), '.abap-adt-mcp', 'ticket.json');
|
||||
|
||||
const BIND_ADDRESS = '127.0.0.1'; // loopback only, by design
|
||||
const REDIRECT_PATH = '/adt/redirect';
|
||||
const TICKET_PARAM = 'reentrance-ticket';
|
||||
const TICKET_ENDPOINT = '/sap/bc/adt/core/http/reentranceticket';
|
||||
const DISCOVERY_PATH = '/sap/bc/adt/core/discovery';
|
||||
|
||||
/**
|
||||
* RFC 6265 cookie-octet. Copied verbatim from abap-adt-mcp 2.1.0
|
||||
* dist/lib/sso2TicketProvider.js (read 2026-09-18): a ticket that fails this
|
||||
* test is rejected by the MCP server itself, so fail here instead of writing a
|
||||
* file that cannot work. Note \x3d (=) IS allowed, so base64 padding is fine;
|
||||
* space, comma, semicolon, double quote and backslash are not.
|
||||
*/
|
||||
const COOKIE_OCTET = /^[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]+$/;
|
||||
const MAX_TICKET_CHARS = 64 * 1024; // same ceiling as the MCP server
|
||||
|
||||
/** Hosts a browser may legitimately use to reach this listener. */
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
||||
|
||||
const EXIT_OK = 0;
|
||||
const EXIT_USAGE = 2;
|
||||
const EXIT_TIMEOUT = 3;
|
||||
const EXIT_BIND = 4;
|
||||
const EXIT_BAD_TICKET = 5;
|
||||
const EXIT_INTERRUPTED = 130;
|
||||
|
||||
/** Every human-readable message goes to stderr. Never pass ticket material. */
|
||||
const note = (msg = '') => process.stderr.write(`${msg}\n`);
|
||||
|
||||
function usage() {
|
||||
note(`adt-reentrance-login — capture an ADT reentrance ticket via a localhost redirect
|
||||
|
||||
node login.mjs [options]
|
||||
|
||||
--tenant <url> S/4HANA tenant base URL (default ${DEFAULT_TENANT})
|
||||
--port <n> loopback port to listen on (default ${DEFAULT_PORT}, bound to ${BIND_ADDRESS} only)
|
||||
--timeout <seconds> give up after this long (default ${DEFAULT_TIMEOUT_SECONDS})
|
||||
--out <path> where to write {"ticket":"..."} (default ${DEFAULT_OUT}, mode 0600)
|
||||
--client <nnn> add sap-client=<nnn> to the logon and probe URLs (default: not sent)
|
||||
--allow-host <name> additionally accept this Host header (for tunnelled forwards)
|
||||
--probe after capture, test the ticket as a cookie and as a header
|
||||
--print-url-only print the logon URL to stdout and exit; start no listener
|
||||
--help this text
|
||||
|
||||
Exit codes: 0 captured, 2 usage, 3 timeout, 4 cannot bind, 5 bad/absent ticket.`);
|
||||
}
|
||||
|
||||
function fail(code, msg) {
|
||||
note(`ERROR: ${msg}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let values;
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
options: {
|
||||
tenant: { type: 'string', default: DEFAULT_TENANT },
|
||||
port: { type: 'string', default: String(DEFAULT_PORT) },
|
||||
timeout: { type: 'string', default: String(DEFAULT_TIMEOUT_SECONDS) },
|
||||
out: { type: 'string', default: DEFAULT_OUT },
|
||||
client: { type: 'string' },
|
||||
'allow-host': { type: 'string', multiple: true, default: [] },
|
||||
probe: { type: 'boolean', default: false },
|
||||
'print-url-only': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
usage();
|
||||
fail(EXIT_USAGE, err.message);
|
||||
}
|
||||
|
||||
if (values.help) {
|
||||
usage();
|
||||
process.exit(EXIT_OK);
|
||||
}
|
||||
|
||||
let tenant;
|
||||
try {
|
||||
const parsed = new URL(values.tenant);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error('tenant must be an http(s) URL');
|
||||
}
|
||||
tenant = `${parsed.protocol}//${parsed.host}`;
|
||||
} catch (err) {
|
||||
fail(EXIT_USAGE, `--tenant is not a usable URL: ${err.message}`);
|
||||
}
|
||||
|
||||
const port = Number(values.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
fail(EXIT_USAGE, `--port must be an integer 1..65535 (got ${JSON.stringify(values.port)})`);
|
||||
}
|
||||
|
||||
const timeoutSeconds = Number(values.timeout);
|
||||
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
||||
fail(EXIT_USAGE, `--timeout must be a positive number of seconds (got ${JSON.stringify(values.timeout)})`);
|
||||
}
|
||||
|
||||
const client = values.client;
|
||||
if (client !== undefined && !/^[0-9]{3}$/.test(client)) {
|
||||
fail(EXIT_USAGE, '--client must be a three-digit SAP client number');
|
||||
}
|
||||
|
||||
const outPath = values.out;
|
||||
const allowedHosts = new Set([...LOOPBACK_HOSTS, ...values['allow-host']]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logon URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built by string concatenation on purpose: ADT for VS Code sends the
|
||||
* redirect-url value literally (observed 2026-09-16), and URLSearchParams would
|
||||
* percent-encode the ':' and '/'. Colons and slashes are legal in a query
|
||||
* string (RFC 3986), so the literal form is both valid and wire-identical to
|
||||
* what the tenant already accepts from ADT.
|
||||
*/
|
||||
function buildLogonUrl() {
|
||||
const redirect = `http://localhost:${port}${REDIRECT_PATH}`;
|
||||
const clientParam = client ? `&sap-client=${encodeURIComponent(client)}` : '';
|
||||
return `${tenant}${TICKET_ENDPOINT}?redirect-url=${redirect}${clientParam}`;
|
||||
}
|
||||
|
||||
const logonUrl = buildLogonUrl();
|
||||
|
||||
if (values['print-url-only']) {
|
||||
// The only thing this script ever writes to stdout. Contains no secret.
|
||||
process.stdout.write(`${logonUrl}\n`);
|
||||
process.exit(EXIT_OK);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ticket extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull the ticket out of the raw request target.
|
||||
*
|
||||
* Deliberately NOT URLSearchParams: it applies
|
||||
* application/x-www-form-urlencoded rules and turns a literal '+' into a space
|
||||
* (measured on Node v24.15.0, 2026-09-18: '?t=AB%2BCD+EF%3d' -> 'AB+CD EF=').
|
||||
* A space is not a legal cookie-octet, so a base64 ticket carrying '+' would be
|
||||
* silently corrupted and then rejected. Splitting the raw query and decoding
|
||||
* once with decodeURIComponent preserves '+' ('AB+CD+EF=').
|
||||
*
|
||||
* Returns { ok: true, ticket } or { ok: false, reason, names }.
|
||||
* `reason` NEVER contains ticket material.
|
||||
*/
|
||||
function extractTicket(requestTarget) {
|
||||
const q = requestTarget.indexOf('?');
|
||||
if (q < 0) return { ok: false, reason: 'the redirect carried no query string', names: [] };
|
||||
|
||||
const names = [];
|
||||
let raw = null;
|
||||
for (const pair of requestTarget.slice(q + 1).split('&')) {
|
||||
if (!pair) continue;
|
||||
const eq = pair.indexOf('=');
|
||||
const rawName = eq < 0 ? pair : pair.slice(0, eq);
|
||||
let name = rawName;
|
||||
try { name = decodeURIComponent(rawName); } catch { /* keep the raw name */ }
|
||||
names.push(name);
|
||||
if (name === TICKET_PARAM && raw === null) raw = eq < 0 ? '' : pair.slice(eq + 1);
|
||||
}
|
||||
|
||||
if (raw === null) return { ok: false, reason: `the redirect carried no ${TICKET_PARAM} parameter`, names };
|
||||
if (raw === '') return { ok: false, reason: `${TICKET_PARAM} was empty`, names };
|
||||
|
||||
let ticket;
|
||||
try {
|
||||
ticket = decodeURIComponent(raw); // exactly once; do not re-encode
|
||||
} catch {
|
||||
return { ok: false, reason: `${TICKET_PARAM} is not valid percent-encoding`, names };
|
||||
}
|
||||
|
||||
if (ticket.length === 0) return { ok: false, reason: `${TICKET_PARAM} decoded to an empty string`, names };
|
||||
if (ticket.length > MAX_TICKET_CHARS) {
|
||||
return { ok: false, reason: `ticket is longer than ${MAX_TICKET_CHARS} characters; abap-adt-mcp would reject it`, names };
|
||||
}
|
||||
if (!COOKIE_OCTET.test(ticket)) {
|
||||
// No positions, no sample characters — that would leak ticket material.
|
||||
return { ok: false, reason: 'ticket contains characters outside the RFC 6265 cookie-octet set', names };
|
||||
}
|
||||
return { ok: true, ticket };
|
||||
}
|
||||
|
||||
/** Write {"ticket":"..."} with 0600 before any ticket byte reaches the disk. */
|
||||
function writeTicketFile(path, ticket) {
|
||||
const dir = dirname(path);
|
||||
const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (created) chmodSync(created, 0o700); // only tighten what we created
|
||||
|
||||
// 'w' truncates on open; fchmod before write closes the window in which a
|
||||
// pre-existing world-readable file would briefly hold the ticket.
|
||||
const fd = openSync(path, 'w', 0o600);
|
||||
try {
|
||||
fchmodSync(fd, 0o600);
|
||||
writeSync(fd, `${JSON.stringify({ ticket })}\n`);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Probe (--probe)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One GET to the ADT discovery endpoint carrying the ticket either as a
|
||||
* MYSAPSSO2 cookie or as a MYSAPSSO2 header, and nothing else. Resolves to
|
||||
* status/redirect-host/session-cookie facts only — never headers, never a body.
|
||||
*/
|
||||
function probeOnce(mode, ticket) {
|
||||
return new Promise((resolve) => {
|
||||
const target = new URL(DISCOVERY_PATH, tenant);
|
||||
if (client) target.searchParams.set('sap-client', client);
|
||||
|
||||
const headers = { Accept: 'application/atomsvc+xml' };
|
||||
if (mode === 'cookie') headers.Cookie = `MYSAPSSO2=${ticket}`;
|
||||
else headers.MYSAPSSO2 = ticket;
|
||||
|
||||
const req = httpsRequest(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port || 443,
|
||||
path: `${target.pathname}${target.search}`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: 30_000,
|
||||
},
|
||||
(res) => {
|
||||
const setCookie = res.headers['set-cookie'] ?? [];
|
||||
const sessionCookie = setCookie.some((c) => /^\s*SAP_SESSIONID/i.test(c));
|
||||
let redirectHost = null;
|
||||
if (res.headers.location) {
|
||||
// Host only: a SAML redirect URL can carry request material.
|
||||
try { redirectHost = new URL(res.headers.location, target).host; }
|
||||
catch { redirectHost = '(unparseable)'; }
|
||||
}
|
||||
res.resume(); // discard the body; it is never read or logged
|
||||
res.on('end', () => resolve({ status: res.statusCode, sessionCookie, redirectHost }));
|
||||
},
|
||||
);
|
||||
req.on('timeout', () => req.destroy(new Error('request timed out')));
|
||||
req.on('error', (err) => resolve({ error: err.code ?? err.message }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function describeProbe(label, result) {
|
||||
if (result.error) return ` ${label}: transport error (${result.error})`;
|
||||
const bits = [`HTTP ${result.status}`];
|
||||
bits.push(result.sessionCookie ? 'set-cookie SAP_SESSIONID: yes' : 'set-cookie SAP_SESSIONID: no');
|
||||
if (result.redirectHost) bits.push(`redirect host: ${result.redirectHost}`);
|
||||
return ` ${label}: ${bits.join(' ')}`;
|
||||
}
|
||||
|
||||
async function runProbe(ticket) {
|
||||
note('');
|
||||
note(`Probing ${tenant}${DISCOVERY_PATH} (Accept: application/atomsvc+xml, no other credentials)...`);
|
||||
const asCookie = await probeOnce('cookie', ticket);
|
||||
const asHeader = await probeOnce('header', ticket);
|
||||
|
||||
note(describeProbe('(a) MYSAPSSO2 cookie', asCookie));
|
||||
note(describeProbe('(b) MYSAPSSO2 header', asHeader));
|
||||
|
||||
const cookieOk = asCookie.status === 200;
|
||||
const headerOk = asHeader.status === 200;
|
||||
if (cookieOk && headerOk) note("verdict: both worked — abap-adt-mcp's cookie-based sso2 mode can work.");
|
||||
else if (cookieOk) note("verdict: the COOKIE worked — abap-adt-mcp's sso2 mode (MYSAPSSO2 cookie) can work.");
|
||||
else if (headerOk) note('verdict: only the HEADER worked — abap-adt-mcp 2.1.0 sends a cookie, so its sso2 mode cannot use this ticket as-is.');
|
||||
else note('verdict: neither returned HTTP 200 — this ticket is not accepted by either transport (expired, wrong client, or wrong mechanism).');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One-shot loopback listener
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_OK = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>ADT logon captured</title></head>
|
||||
<body style="font-family:system-ui,sans-serif;max-width:34rem;margin:4rem auto">
|
||||
<h1>Ticket captured</h1>
|
||||
<p>The reentrance ticket was written to the login helper's output file. You can close this tab.</p>
|
||||
<p><small>This URL contains a live credential and stays in your browser history. Clear it if the machine is shared.</small></p>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
const PAGE_FAIL = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>ADT logon failed</title></head>
|
||||
<body style="font-family:system-ui,sans-serif;max-width:34rem;margin:4rem auto">
|
||||
<h1>No usable ticket</h1>
|
||||
<p>The redirect did not carry a usable reentrance ticket. See the helper's terminal output.</p>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
let finished = false;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// Keep the shared secret-free headers on every answer.
|
||||
const send = (status, body, cb) => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
Connection: 'close',
|
||||
});
|
||||
if (req.method === 'HEAD') res.end(cb);
|
||||
else res.end(body, cb);
|
||||
};
|
||||
|
||||
if (finished) { send(503, PAGE_FAIL); return; }
|
||||
|
||||
const target = req.url ?? '/';
|
||||
const pathOnly = target.split('?')[0];
|
||||
|
||||
// Dropped rather than fatal: browsers ask for /favicon.ico unprompted.
|
||||
if (pathOnly !== REDIRECT_PATH) { send(404, PAGE_FAIL); return; }
|
||||
|
||||
// Cheap DNS-rebinding guard. The socket is loopback-only anyway.
|
||||
const hostHeader = (req.headers.host ?? '').replace(/:\d+$/, '');
|
||||
if (hostHeader && !allowedHosts.has(hostHeader)) {
|
||||
note(`Refused a redirect with Host: ${hostHeader} (expected localhost). Re-run with --allow-host ${hostHeader} if that forward is yours.`);
|
||||
send(403, PAGE_FAIL);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = extractTicket(target);
|
||||
if (!result.ok) {
|
||||
finished = true;
|
||||
send(400, PAGE_FAIL, () => {
|
||||
note(`Redirect received on ${REDIRECT_PATH}, but ${result.reason}.`);
|
||||
if (result.names?.length) note(`Parameter names present (values withheld): ${result.names.join(', ')}`);
|
||||
server.close();
|
||||
process.exit(EXIT_BAD_TICKET);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
try {
|
||||
writeTicketFile(outPath, result.ticket);
|
||||
} catch (err) {
|
||||
send(500, PAGE_FAIL, () => {
|
||||
note(`ERROR: could not write ${outPath}: ${err.message}`);
|
||||
server.close();
|
||||
process.exit(EXIT_BAD_TICKET);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send(200, PAGE_OK, async () => {
|
||||
note(`Ticket captured and written to ${outPath} (mode 0600). Its value is not shown anywhere.`);
|
||||
if (values.probe) {
|
||||
try { await runProbe(result.ticket); }
|
||||
catch (err) { note(`Probe failed to run: ${err.message}`); }
|
||||
}
|
||||
server.close();
|
||||
process.exit(EXIT_OK);
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
fail(EXIT_BIND, `port ${port} is already in use on ${BIND_ADDRESS}. Pick another with --port (and forward that one).`);
|
||||
}
|
||||
fail(EXIT_BIND, `cannot listen on ${BIND_ADDRESS}:${port}: ${err.message}`);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
note(`Timed out after ${timeoutSeconds}s without a redirect. Nothing was written.`);
|
||||
server.close();
|
||||
process.exit(EXIT_TIMEOUT);
|
||||
}, timeoutSeconds * 1000);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
clearTimeout(timer);
|
||||
note('Interrupted. Nothing was written.');
|
||||
server.close();
|
||||
process.exit(EXIT_INTERRUPTED);
|
||||
});
|
||||
|
||||
server.listen({ host: BIND_ADDRESS, port, exclusive: true }, () => {
|
||||
note(`Listening on http://${BIND_ADDRESS}:${port}${REDIRECT_PATH} (loopback only) for ${timeoutSeconds}s.`);
|
||||
note('');
|
||||
note(`1. Forward TCP port ${port} from this host to your machine.`);
|
||||
note(` VS Code: Ports panel -> Forward a Port -> ${port} (or: ssh -L ${port}:127.0.0.1:${port} <this-host>)`);
|
||||
note(' Your browser must reach THIS host\'s localhost, or the redirect lands nowhere.');
|
||||
note('2. Open this URL in that browser and complete SSO:');
|
||||
note('');
|
||||
note(` ${logonUrl}`);
|
||||
note('');
|
||||
note(`3. The tenant redirects to http://localhost:${port}${REDIRECT_PATH}?... and the ticket is captured here.`);
|
||||
note(` It is written to ${outPath} only. Never paste the redirect URL into a chat, log or file.`);
|
||||
});
|
||||
Reference in New Issue
Block a user