#!/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:///sap/bc/adt/core/http/reentranceticket?redirect-url=http://localhost:/adt/redirect * and reading the ticket out of the redirect * http://localhost:/adt/redirect?...&reentrance-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 S/4HANA tenant base URL (default ${DEFAULT_TENANT}) --port loopback port to listen on (default ${DEFAULT_PORT}, bound to ${BIND_ADDRESS} only) --timeout give up after this long (default ${DEFAULT_TIMEOUT_SECONDS}) --out where to write {"ticket":"..."} (default ${DEFAULT_OUT}, mode 0600) --client add sap-client= to the logon and probe URLs (default: not sent) --allow-host 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 = ` ADT logon captured

Ticket captured

The reentrance ticket was written to the login helper's output file. You can close this tab.

This URL contains a live credential and stays in your browser history. Clear it if the machine is shared.

`; const PAGE_FAIL = ` ADT logon failed

No usable ticket

The redirect did not carry a usable reentrance ticket. See the helper's terminal output.

`; 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} )`); 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.`); });