@mcp-abap-adt/connection 5.0.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +243 -1
- package/README.md +204 -48
- package/bin/sap-abap-auth.js +101 -13
- package/dist/auth/providers.d.ts +38 -9
- package/dist/auth/providers.d.ts.map +1 -1
- package/dist/auth/providers.js +46 -3
- package/dist/connection/AbstractAbapConnection.d.ts +83 -136
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +241 -763
- package/dist/connection/AdtCloudConnector.d.ts +12 -7
- package/dist/connection/AdtCloudConnector.d.ts.map +1 -1
- package/dist/connection/AdtCloudConnector.js +2 -6
- package/dist/connection/AdtOnPremConnector.d.ts +20 -7
- package/dist/connection/AdtOnPremConnector.d.ts.map +1 -1
- package/dist/connection/AdtOnPremConnector.js +2 -6
- package/dist/connection/CloudHttpTransport.d.ts +37 -0
- package/dist/connection/CloudHttpTransport.d.ts.map +1 -0
- package/dist/{session/CloudSecuritySessionStrategy.js → connection/CloudHttpTransport.js} +59 -49
- package/dist/connection/CredentialAbapConnection.d.ts +8 -41
- package/dist/connection/CredentialAbapConnection.d.ts.map +1 -1
- package/dist/connection/CredentialAbapConnection.js +44 -111
- package/dist/connection/HttpTransport.d.ts +178 -0
- package/dist/connection/HttpTransport.d.ts.map +1 -0
- package/dist/connection/HttpTransport.js +402 -0
- package/dist/connection/IAdtTransport.d.ts +232 -0
- package/dist/connection/IAdtTransport.d.ts.map +1 -0
- package/dist/connection/IAdtTransport.js +28 -0
- package/dist/connection/LegacyOnPremHttpTransport.d.ts +40 -0
- package/dist/connection/LegacyOnPremHttpTransport.d.ts.map +1 -0
- package/dist/connection/LegacyOnPremHttpTransport.js +57 -0
- package/dist/connection/OnPremHttpTransport.d.ts +45 -0
- package/dist/connection/OnPremHttpTransport.d.ts.map +1 -0
- package/dist/connection/OnPremHttpTransport.js +91 -0
- package/dist/connection/RfcTransport.d.ts +89 -0
- package/dist/connection/RfcTransport.d.ts.map +1 -0
- package/dist/connection/RfcTransport.js +270 -0
- package/dist/connection/rfcConversation.d.ts +44 -0
- package/dist/connection/rfcConversation.d.ts.map +1 -0
- package/dist/connection/rfcConversation.js +71 -0
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -18
- package/dist/utils/timeouts.d.ts +6 -19
- package/dist/utils/timeouts.d.ts.map +1 -1
- package/dist/utils/timeouts.js +6 -22
- package/docs/INDEX.md +5 -2
- package/docs/INSTALLATION.md +28 -10
- package/docs/JWT_AUTH_TOOLS.md +20 -4
- package/docs/MIGRATION-6.0.md +359 -0
- package/docs/SCOPE.md +1 -1
- package/docs/STATEFUL_SESSION_GUIDE.md +86 -17
- package/docs/USAGE.md +260 -119
- package/examples/basic-connection.js +15 -3
- package/examples/jwt-with-token-refresh.js +15 -7
- package/examples/saml-connection.js +15 -2
- package/package.json +12 -11
- package/dist/__tests__/helpers/session.d.ts +0 -15
- package/dist/__tests__/helpers/session.d.ts.map +0 -1
- package/dist/__tests__/helpers/session.js +0 -19
- package/dist/auth/IAuthProvider.d.ts +0 -84
- package/dist/auth/IAuthProvider.d.ts.map +0 -1
- package/dist/auth/IAuthProvider.js +0 -21
- package/dist/connection/BaseAbapConnection.d.ts +0 -29
- package/dist/connection/BaseAbapConnection.d.ts.map +0 -1
- package/dist/connection/BaseAbapConnection.js +0 -81
- package/dist/connection/CertificateAbapConnection.d.ts +0 -35
- package/dist/connection/CertificateAbapConnection.d.ts.map +0 -1
- package/dist/connection/CertificateAbapConnection.js +0 -91
- package/dist/connection/JwtAbapConnection.d.ts +0 -131
- package/dist/connection/JwtAbapConnection.d.ts.map +0 -1
- package/dist/connection/JwtAbapConnection.js +0 -376
- package/dist/connection/KerberosAbapConnection.d.ts +0 -32
- package/dist/connection/KerberosAbapConnection.d.ts.map +0 -1
- package/dist/connection/KerberosAbapConnection.js +0 -128
- package/dist/connection/RfcAbapConnection.d.ts +0 -44
- package/dist/connection/RfcAbapConnection.d.ts.map +0 -1
- package/dist/connection/RfcAbapConnection.js +0 -324
- package/dist/connection/SamlAbapConnection.d.ts +0 -31
- package/dist/connection/SamlAbapConnection.d.ts.map +0 -1
- package/dist/connection/SamlAbapConnection.js +0 -81
- package/dist/connection/connectionFactory.d.ts +0 -25
- package/dist/connection/connectionFactory.d.ts.map +0 -1
- package/dist/connection/connectionFactory.js +0 -84
- package/dist/session/CloudSecuritySessionStrategy.d.ts +0 -32
- package/dist/session/CloudSecuritySessionStrategy.d.ts.map +0 -1
- package/dist/session/IcfSessionStrategy.d.ts +0 -27
- package/dist/session/IcfSessionStrategy.d.ts.map +0 -1
- package/dist/session/IcfSessionStrategy.js +0 -62
- package/dist/session/SessionStrategy.d.ts +0 -86
- package/dist/session/SessionStrategy.d.ts.map +0 -1
- package/dist/session/SessionStrategy.js +0 -33
- package/docs/superpowers/specs/2026-08-21-platform-connectors.md +0 -108
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ADT request carried over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* The obvious one, and until now the one that did not exist as a thing: RFC was
|
|
5
|
+
* an object while HTTP was a branch inside `getAxiosInstance()`. An axis with a
|
|
6
|
+
* code path on one end cannot be named in a type — a default type parameter has
|
|
7
|
+
* nothing to point at — so this is what makes the two ends symmetrical.
|
|
8
|
+
*
|
|
9
|
+
* No `open()` or `close()`. A request opens its own socket and there is no
|
|
10
|
+
* conversation to establish or give back; those members exist for a transport
|
|
11
|
+
* that owns a wire, which is RFC.
|
|
12
|
+
*
|
|
13
|
+
* **Where the two axes touch.** TLS client-certificate material comes from the
|
|
14
|
+
* CREDENTIAL — a certificate authenticates through the transport rather than
|
|
15
|
+
* through a header — and configures this. It is taken as a thunk rather than a
|
|
16
|
+
* value so it is read when the client is first built, by which time the
|
|
17
|
+
* credential has been prepared and knows what it holds. Read in a constructor,
|
|
18
|
+
* it would be whatever was loaded before the connection started, which for a
|
|
19
|
+
* certificate is nothing.
|
|
20
|
+
*/
|
|
21
|
+
import { type AgentOptions } from 'node:https';
|
|
22
|
+
import type { ILogger } from '../logger.js';
|
|
23
|
+
import type { IAdtEstablishContext, IAdtSessionContext, IAdtTransport, IAdtTransportRequest, IAdtTransportResponse } from './IAdtTransport.js';
|
|
24
|
+
export declare class HttpTransport implements IAdtTransport {
|
|
25
|
+
private readonly agentOptions;
|
|
26
|
+
protected readonly logger: ILogger | null;
|
|
27
|
+
/**
|
|
28
|
+
* `client` because SAP answers `sap-usercontext` with the system default
|
|
29
|
+
* rather than the client that was asked for, and later requests then route
|
|
30
|
+
* to a client the caller never named — on a read-only one, every write
|
|
31
|
+
* comes back 403.
|
|
32
|
+
*/
|
|
33
|
+
private readonly options;
|
|
34
|
+
/**
|
|
35
|
+
* A real wire, usable on its own: it sends, holds a jar, and earns a CSRF
|
|
36
|
+
* token. What the two subclasses add is not the wire but the SESSION
|
|
37
|
+
* MECHANISM — the cloud resource, the platform logoff — which is a different
|
|
38
|
+
* question and the one the consumer answers by taking one of them.
|
|
39
|
+
*
|
|
40
|
+
* A bare one therefore does something well defined: it carries requests and
|
|
41
|
+
* never asks for or gives back a session. Which system a connector is for is
|
|
42
|
+
* enforced by its type parameter, not by this being unusable.
|
|
43
|
+
*/
|
|
44
|
+
readonly kind: string;
|
|
45
|
+
private instance;
|
|
46
|
+
/**
|
|
47
|
+
* The wire's own state.
|
|
48
|
+
*
|
|
49
|
+
* Here rather than on the connection because it is what HTTP *is*: a cookie
|
|
50
|
+
* jar, the session those cookies address, and the application server the
|
|
51
|
+
* session lives on. A connection that held these would be holding them for
|
|
52
|
+
* every transport, including one that can never fill them.
|
|
53
|
+
*/
|
|
54
|
+
private readonly jar;
|
|
55
|
+
private combined;
|
|
56
|
+
private appServer;
|
|
57
|
+
private token;
|
|
58
|
+
constructor(agentOptions?: () => AgentOptions, logger?: ILogger | null,
|
|
59
|
+
/**
|
|
60
|
+
* `client` because SAP answers `sap-usercontext` with the system default
|
|
61
|
+
* rather than the client that was asked for, and later requests then route
|
|
62
|
+
* to a client the caller never named — on a read-only one, every write
|
|
63
|
+
* comes back 403.
|
|
64
|
+
*/
|
|
65
|
+
options?: {
|
|
66
|
+
client?: string;
|
|
67
|
+
baseUrl?: string;
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Fold a response into the wire state.
|
|
71
|
+
*
|
|
72
|
+
* Says nothing about what the change MEANS — whether a new session id is an
|
|
73
|
+
* establishment or a replacement is a question about the session's lifetime,
|
|
74
|
+
* which this has no way to answer and no business answering.
|
|
75
|
+
*/
|
|
76
|
+
ingest(headers?: Record<string, unknown>): void;
|
|
77
|
+
/** What to put on the `Cookie` header, or nothing if the jar is empty. */
|
|
78
|
+
cookies(): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Which ABAP session this wire is on.
|
|
81
|
+
*
|
|
82
|
+
* `SAP_SESSIONID` and nothing else: `sap-usercontext` is ours and does not
|
|
83
|
+
* name a session, and a fingerprint that moved when it did would report a
|
|
84
|
+
* replacement every time the client was re-asserted.
|
|
85
|
+
*/
|
|
86
|
+
sessionFingerprint(): Map<string, string>;
|
|
87
|
+
/**
|
|
88
|
+
* A session exists when the server named one.
|
|
89
|
+
*
|
|
90
|
+
* `SAP_SESSIONID` is what a lock is bound to, so its absence means there is
|
|
91
|
+
* nothing to hold one — the connection can read and can keep nothing.
|
|
92
|
+
*/
|
|
93
|
+
sessionEstablished(): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Headers that keep this connection on the server its session lives on.
|
|
96
|
+
*
|
|
97
|
+
* `sap-adt-saplb: fetch` asks the server to name itself — it answers on every
|
|
98
|
+
* request, so the binding survives a restart that moves us. `saplb` is that
|
|
99
|
+
* name sent back. `REDISPATCH_ON_SHUTDOWN` is what Eclipse asks for: if the
|
|
100
|
+
* server is going down, send us elsewhere rather than fail.
|
|
101
|
+
*/
|
|
102
|
+
affinityHeaders(): Record<string, string>;
|
|
103
|
+
/**
|
|
104
|
+
* Earn a CSRF token, and with it the cookies that name the session.
|
|
105
|
+
*
|
|
106
|
+
* The token and the session are one thing on this wire: SAP binds a lock
|
|
107
|
+
* handle to the `SAP_SESSIONID` the same exchange sets, so a token kept
|
|
108
|
+
* across a new session would be presented against a session it was never
|
|
109
|
+
* issued for.
|
|
110
|
+
*/
|
|
111
|
+
establish(context: IAdtEstablishContext): Promise<void>;
|
|
112
|
+
/** The token this wire earned, or nothing if it has not earned one. */
|
|
113
|
+
csrfToken(): string | null;
|
|
114
|
+
adoptCsrfToken(token: string | null): void;
|
|
115
|
+
/** Drop everything the wire was holding. The socket outlives none of it. */
|
|
116
|
+
forgetSession(): void;
|
|
117
|
+
/**
|
|
118
|
+
* Take the application server's name from a response, if it named one. Only
|
|
119
|
+
* ever from the server's own answer — never guessed.
|
|
120
|
+
*/
|
|
121
|
+
private rememberAppServer;
|
|
122
|
+
/**
|
|
123
|
+
* Everything this wire adds to a request of its own accord: the cookies it
|
|
124
|
+
* holds, and the headers that keep it on the server its session lives on.
|
|
125
|
+
*
|
|
126
|
+
* Here rather than on the connection because they are HTTP's — a cookie jar
|
|
127
|
+
* and a dispatcher to stay bound to — and a connection that threaded them
|
|
128
|
+
* would be threading them for every wire, including one that has neither.
|
|
129
|
+
*
|
|
130
|
+
* MERGED with whatever the caller set: a SAML session IS the credential's
|
|
131
|
+
* cookie, and replacing it would send the request out unauthenticated while
|
|
132
|
+
* looking like it carried a session.
|
|
133
|
+
*/
|
|
134
|
+
private dress;
|
|
135
|
+
/** A path becomes an address; anything already absolute is left alone. */
|
|
136
|
+
private address;
|
|
137
|
+
private client;
|
|
138
|
+
/**
|
|
139
|
+
* Nothing to ask for: an HTTP session arrives with the establishing call.
|
|
140
|
+
*
|
|
141
|
+
* Empty rather than absent. "There is no session resource here" is a fact
|
|
142
|
+
* about this wire, and a fact is stated, not left for a caller to discover by
|
|
143
|
+
* checking whether the method exists.
|
|
144
|
+
*/
|
|
145
|
+
open(_context: IAdtSessionContext): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Nothing to give back at this level.
|
|
148
|
+
*
|
|
149
|
+
* The two concrete wires override it — the cloud one DELETEs the session
|
|
150
|
+
* resource, the on-prem one sends the platform logoff. A bare HTTP wire has
|
|
151
|
+
* neither, and says so by doing nothing.
|
|
152
|
+
*/
|
|
153
|
+
close(_context: IAdtSessionContext): Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Throws for a status the request does not admit — by doing nothing, because
|
|
156
|
+
* that is already what axios does, and `AxiosError` already carries
|
|
157
|
+
* `response`. The contract was written to describe this behaviour rather than
|
|
158
|
+
* to add it.
|
|
159
|
+
*/
|
|
160
|
+
send(request: IAdtTransportRequest): Promise<IAdtTransportResponse>;
|
|
161
|
+
/**
|
|
162
|
+
* Send EXACTLY these headers, with nothing of the wire's live state mixed in.
|
|
163
|
+
*
|
|
164
|
+
* For a goodbye, and only for a goodbye. `disconnect()` dispatches the logoff
|
|
165
|
+
* without awaiting it, so the request is still being assembled while the
|
|
166
|
+
* connection is already free to `connect()` again — and by the time it goes
|
|
167
|
+
* out, the jar can hold a different session. Dressing it then merges the LIVE
|
|
168
|
+
* `SAP_SESSIONID` over the snapshot, and `mergeCookieHeaders` lets the later
|
|
169
|
+
* value win on a repeated name, so the goodbye for the old session arrives
|
|
170
|
+
* addressed to the new one and closes it.
|
|
171
|
+
*
|
|
172
|
+
* Reading the cookies synchronously is necessary and was not sufficient: the
|
|
173
|
+
* snapshot survived only until the send path put the jar back on top of it.
|
|
174
|
+
*/
|
|
175
|
+
protected sendDetached(request: IAdtTransportRequest): Promise<IAdtTransportResponse>;
|
|
176
|
+
private dispatch;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=HttpTransport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"HttpTransport.d.ts","sourceRoot":"","sources":["../../src/connection/HttpTransport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAEtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,KAAK,EACV,oBAAoB,EACpB,kBAAkB,EAClB,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,oBAAoB,CAAC;AAS5B,qBAAa,aAAc,YAAW,aAAa;IA6B/C,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;IACzC;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,OAAO;IApC1B;;;;;;;;;OASG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAU;IAE/B,OAAO,CAAC,QAAQ,CAA8B;IAE9C;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,GAAG,CAA6B;IACjD,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,KAAK,CAAuB;gBAGjB,YAAY,GAAE,MAAM,YAAyB,EAC3C,MAAM,GAAE,OAAO,GAAG,IAAW;IAChD;;;;;OAKG;IACc,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;IAGtE;;;;;;OAMG;IACH,MAAM,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgC/C,0EAA0E;IAC1E,OAAO,IAAI,MAAM,GAAG,IAAI;IAIxB;;;;;;OAMG;IACH,kBAAkB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;IAQzC;;;;;OAKG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;;;;;;OAOG;IACH,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IASzC;;;;;;;OAOG;IACG,SAAS,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmG7D,uEAAuE;IACvE,SAAS,IAAI,MAAM,GAAG,IAAI;IAI1B,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI1C,4EAA4E;IAC5E,aAAa,IAAI,IAAI;IASrB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAWzB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,KAAK;IAab,0EAA0E;IAC1E,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,MAAM;IAuBd;;;;;;OAMG;IACG,IAAI,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAEvD;;;;;;OAMG;IACG,KAAK,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAExD;;;;;OAKG;IACG,IAAI,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAIzE;;;;;;;;;;;;;OAaG;cACa,YAAY,CAC1B,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;YAInB,QAAQ;CA4BvB"}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* An ADT request carried over HTTP.
|
|
4
|
+
*
|
|
5
|
+
* The obvious one, and until now the one that did not exist as a thing: RFC was
|
|
6
|
+
* an object while HTTP was a branch inside `getAxiosInstance()`. An axis with a
|
|
7
|
+
* code path on one end cannot be named in a type — a default type parameter has
|
|
8
|
+
* nothing to point at — so this is what makes the two ends symmetrical.
|
|
9
|
+
*
|
|
10
|
+
* No `open()` or `close()`. A request opens its own socket and there is no
|
|
11
|
+
* conversation to establish or give back; those members exist for a transport
|
|
12
|
+
* that owns a wire, which is RFC.
|
|
13
|
+
*
|
|
14
|
+
* **Where the two axes touch.** TLS client-certificate material comes from the
|
|
15
|
+
* CREDENTIAL — a certificate authenticates through the transport rather than
|
|
16
|
+
* through a header — and configures this. It is taken as a thunk rather than a
|
|
17
|
+
* value so it is read when the client is first built, by which time the
|
|
18
|
+
* credential has been prepared and knows what it holds. Read in a constructor,
|
|
19
|
+
* it would be whatever was loaded before the connection started, which for a
|
|
20
|
+
* certificate is nothing.
|
|
21
|
+
*/
|
|
22
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
23
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.HttpTransport = void 0;
|
|
27
|
+
const node_https_1 = require("node:https");
|
|
28
|
+
const axios_1 = __importDefault(require("axios"));
|
|
29
|
+
const cookies_js_1 = require("../utils/cookies.js");
|
|
30
|
+
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
31
|
+
/** A 404 there means the system has no such endpoint, not that it is unwell. */
|
|
32
|
+
function absentEndpoint(error) {
|
|
33
|
+
const status = error
|
|
34
|
+
?.response?.status;
|
|
35
|
+
return status === 404 || status === 501;
|
|
36
|
+
}
|
|
37
|
+
class HttpTransport {
|
|
38
|
+
agentOptions;
|
|
39
|
+
logger;
|
|
40
|
+
options;
|
|
41
|
+
/**
|
|
42
|
+
* A real wire, usable on its own: it sends, holds a jar, and earns a CSRF
|
|
43
|
+
* token. What the two subclasses add is not the wire but the SESSION
|
|
44
|
+
* MECHANISM — the cloud resource, the platform logoff — which is a different
|
|
45
|
+
* question and the one the consumer answers by taking one of them.
|
|
46
|
+
*
|
|
47
|
+
* A bare one therefore does something well defined: it carries requests and
|
|
48
|
+
* never asks for or gives back a session. Which system a connector is for is
|
|
49
|
+
* enforced by its type parameter, not by this being unusable.
|
|
50
|
+
*/
|
|
51
|
+
kind = 'http';
|
|
52
|
+
instance = null;
|
|
53
|
+
/**
|
|
54
|
+
* The wire's own state.
|
|
55
|
+
*
|
|
56
|
+
* Here rather than on the connection because it is what HTTP *is*: a cookie
|
|
57
|
+
* jar, the session those cookies address, and the application server the
|
|
58
|
+
* session lives on. A connection that held these would be holding them for
|
|
59
|
+
* every transport, including one that can never fill them.
|
|
60
|
+
*/
|
|
61
|
+
jar = new Map();
|
|
62
|
+
combined = null;
|
|
63
|
+
appServer = null;
|
|
64
|
+
token = null;
|
|
65
|
+
constructor(agentOptions = () => ({}), logger = null,
|
|
66
|
+
/**
|
|
67
|
+
* `client` because SAP answers `sap-usercontext` with the system default
|
|
68
|
+
* rather than the client that was asked for, and later requests then route
|
|
69
|
+
* to a client the caller never named — on a read-only one, every write
|
|
70
|
+
* comes back 403.
|
|
71
|
+
*/
|
|
72
|
+
options = {}) {
|
|
73
|
+
this.agentOptions = agentOptions;
|
|
74
|
+
this.logger = logger;
|
|
75
|
+
this.options = options;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fold a response into the wire state.
|
|
79
|
+
*
|
|
80
|
+
* Says nothing about what the change MEANS — whether a new session id is an
|
|
81
|
+
* establishment or a replacement is a question about the session's lifetime,
|
|
82
|
+
* which this has no way to answer and no business answering.
|
|
83
|
+
*/
|
|
84
|
+
ingest(headers) {
|
|
85
|
+
if (!headers)
|
|
86
|
+
return;
|
|
87
|
+
this.rememberAppServer(headers);
|
|
88
|
+
const setCookie = headers['set-cookie'];
|
|
89
|
+
// Nothing was set, so nothing is folded in — not even the client, which is
|
|
90
|
+
// an assertion ON the cookies a response brought rather than a cookie of
|
|
91
|
+
// its own. Asserting it here would give a wire that has never been issued
|
|
92
|
+
// anything a `Cookie` header to send, and the code above reads a non-empty
|
|
93
|
+
// jar as "this connection holds something".
|
|
94
|
+
if (!setCookie)
|
|
95
|
+
return;
|
|
96
|
+
for (const entry of Array.isArray(setCookie) ? setCookie : [setCookie]) {
|
|
97
|
+
if (typeof entry !== 'string')
|
|
98
|
+
continue;
|
|
99
|
+
const [nameValue] = entry.split(';');
|
|
100
|
+
if (!nameValue)
|
|
101
|
+
continue;
|
|
102
|
+
const [name, ...rest] = nameValue.split('=');
|
|
103
|
+
const trimmed = name?.trim();
|
|
104
|
+
if (!trimmed)
|
|
105
|
+
continue;
|
|
106
|
+
this.jar.set(trimmed, rest.join('=').trim());
|
|
107
|
+
}
|
|
108
|
+
if (this.options.client) {
|
|
109
|
+
this.jar.set('sap-usercontext', `sap-client=${this.options.client}`);
|
|
110
|
+
}
|
|
111
|
+
if (this.jar.size === 0)
|
|
112
|
+
return;
|
|
113
|
+
const combined = Array.from(this.jar.entries())
|
|
114
|
+
.map(([name, value]) => (value ? `${name}=${value}` : name))
|
|
115
|
+
.join('; ');
|
|
116
|
+
if (combined)
|
|
117
|
+
this.combined = combined;
|
|
118
|
+
}
|
|
119
|
+
/** What to put on the `Cookie` header, or nothing if the jar is empty. */
|
|
120
|
+
cookies() {
|
|
121
|
+
return this.combined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Which ABAP session this wire is on.
|
|
125
|
+
*
|
|
126
|
+
* `SAP_SESSIONID` and nothing else: `sap-usercontext` is ours and does not
|
|
127
|
+
* name a session, and a fingerprint that moved when it did would report a
|
|
128
|
+
* replacement every time the client was re-asserted.
|
|
129
|
+
*/
|
|
130
|
+
sessionFingerprint() {
|
|
131
|
+
const fingerprint = new Map();
|
|
132
|
+
for (const [name, value] of this.jar) {
|
|
133
|
+
if (name.startsWith('SAP_SESSIONID'))
|
|
134
|
+
fingerprint.set(name, value);
|
|
135
|
+
}
|
|
136
|
+
return fingerprint;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A session exists when the server named one.
|
|
140
|
+
*
|
|
141
|
+
* `SAP_SESSIONID` is what a lock is bound to, so its absence means there is
|
|
142
|
+
* nothing to hold one — the connection can read and can keep nothing.
|
|
143
|
+
*/
|
|
144
|
+
sessionEstablished() {
|
|
145
|
+
return this.sessionFingerprint().size > 0;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Headers that keep this connection on the server its session lives on.
|
|
149
|
+
*
|
|
150
|
+
* `sap-adt-saplb: fetch` asks the server to name itself — it answers on every
|
|
151
|
+
* request, so the binding survives a restart that moves us. `saplb` is that
|
|
152
|
+
* name sent back. `REDISPATCH_ON_SHUTDOWN` is what Eclipse asks for: if the
|
|
153
|
+
* server is going down, send us elsewhere rather than fail.
|
|
154
|
+
*/
|
|
155
|
+
affinityHeaders() {
|
|
156
|
+
return {
|
|
157
|
+
'sap-adt-saplb': 'fetch',
|
|
158
|
+
...(this.appServer
|
|
159
|
+
? { saplb: this.appServer, 'saplb-options': 'REDISPATCH_ON_SHUTDOWN' }
|
|
160
|
+
: {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Earn a CSRF token, and with it the cookies that name the session.
|
|
165
|
+
*
|
|
166
|
+
* The token and the session are one thing on this wire: SAP binds a lock
|
|
167
|
+
* handle to the `SAP_SESSIONID` the same exchange sets, so a token kept
|
|
168
|
+
* across a new session would be presented against a session it was never
|
|
169
|
+
* issued for.
|
|
170
|
+
*/
|
|
171
|
+
async establish(context) {
|
|
172
|
+
// Idempotent. Asked again before a mutation, a wire that already holds a
|
|
173
|
+
// token must not spend a round trip earning another — SAP binds the lock
|
|
174
|
+
// handle to the session the token came with, so a second exchange would
|
|
175
|
+
// move the session out from under a lock taken against the first.
|
|
176
|
+
if (this.token)
|
|
177
|
+
return;
|
|
178
|
+
const base = context.baseUrl.endsWith('/')
|
|
179
|
+
? context.baseUrl.slice(0, -1)
|
|
180
|
+
: context.baseUrl;
|
|
181
|
+
const endpoints = [
|
|
182
|
+
`${base}${csrfConfig_js_1.CSRF_CONFIG.ENDPOINT}`,
|
|
183
|
+
// BASIS < 7.52 has no /sap/bc/adt/core/discovery.
|
|
184
|
+
`${base}${csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT}`,
|
|
185
|
+
];
|
|
186
|
+
const retries = context.retries ?? csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT;
|
|
187
|
+
const delay = context.retryDelayMs ?? csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY;
|
|
188
|
+
let last;
|
|
189
|
+
for (const [index, url] of endpoints.entries()) {
|
|
190
|
+
// The fallback exists for a system that HAS no
|
|
191
|
+
// `/sap/bc/adt/core/discovery` — BASIS < 7.52 — which the server says by
|
|
192
|
+
// answering 404 there. Trying it after a refused connection or a timeout
|
|
193
|
+
// asks a host that is not answering to answer a different path, which
|
|
194
|
+
// doubles the wait before the caller is told what is actually wrong.
|
|
195
|
+
if (index > 0 && !absentEndpoint(last))
|
|
196
|
+
break;
|
|
197
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
// Read per attempt: a provider may renew behind the call.
|
|
200
|
+
const auth = await context.authHeaders();
|
|
201
|
+
const response = await this.send({
|
|
202
|
+
method: 'GET',
|
|
203
|
+
url,
|
|
204
|
+
headers: {
|
|
205
|
+
...auth,
|
|
206
|
+
...context.extraHeaders,
|
|
207
|
+
...csrfConfig_js_1.CSRF_CONFIG.REQUIRED_HEADERS,
|
|
208
|
+
},
|
|
209
|
+
...(context.timeoutMs !== undefined
|
|
210
|
+
? { timeout: context.timeoutMs }
|
|
211
|
+
: {}),
|
|
212
|
+
});
|
|
213
|
+
// Handed up BEFORE the token is read: the cookies are the session,
|
|
214
|
+
// and a fold that only happened on success would lose the session a
|
|
215
|
+
// tokenless answer still opened.
|
|
216
|
+
context.observe(response.headers);
|
|
217
|
+
this.ingest(response.headers);
|
|
218
|
+
const token = response.headers['x-csrf-token'];
|
|
219
|
+
if (token) {
|
|
220
|
+
this.token = token;
|
|
221
|
+
this.logger?.debug('CSRF token obtained');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
last = new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// Not a failed exchange: see `isFatal`. Leaves immediately, past the
|
|
228
|
+
// retries and past the fallback endpoint.
|
|
229
|
+
if (context.isFatal?.(error))
|
|
230
|
+
throw error;
|
|
231
|
+
last = error instanceof Error ? error : new Error(String(error));
|
|
232
|
+
const response = error.response;
|
|
233
|
+
if (response?.headers) {
|
|
234
|
+
// A refusal can still carry the cookies that matter.
|
|
235
|
+
context.observe(response.headers);
|
|
236
|
+
this.ingest(response.headers);
|
|
237
|
+
// …and the token itself. SAP answers 405 to a GET on some
|
|
238
|
+
// endpoints and puts the token in the header anyway, and other
|
|
239
|
+
// refusals carry one too. A retry would throw away a token the
|
|
240
|
+
// server already handed over.
|
|
241
|
+
const onError = response.headers['x-csrf-token'];
|
|
242
|
+
if (onError) {
|
|
243
|
+
this.token = onError;
|
|
244
|
+
this.logger?.debug('CSRF token arrived on a refusal, and is kept');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (attempt < retries) {
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// The original error, with its `response`, when there was one: the
|
|
255
|
+
// classification above this reads `error.response.status`, and a generic
|
|
256
|
+
// replacement would tell it nothing.
|
|
257
|
+
throw last ?? new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
258
|
+
}
|
|
259
|
+
/** The token this wire earned, or nothing if it has not earned one. */
|
|
260
|
+
csrfToken() {
|
|
261
|
+
return this.token;
|
|
262
|
+
}
|
|
263
|
+
adoptCsrfToken(token) {
|
|
264
|
+
this.token = token;
|
|
265
|
+
}
|
|
266
|
+
/** Drop everything the wire was holding. The socket outlives none of it. */
|
|
267
|
+
forgetSession() {
|
|
268
|
+
this.jar.clear();
|
|
269
|
+
this.combined = null;
|
|
270
|
+
this.appServer = null;
|
|
271
|
+
// The token was issued INTO the session being dropped. Kept, it would be
|
|
272
|
+
// presented against a session it was never bound to.
|
|
273
|
+
this.token = null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Take the application server's name from a response, if it named one. Only
|
|
277
|
+
* ever from the server's own answer — never guessed.
|
|
278
|
+
*/
|
|
279
|
+
rememberAppServer(headers) {
|
|
280
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === 'sap-adt-saplb');
|
|
281
|
+
const value = key ? headers[key] : undefined;
|
|
282
|
+
if (typeof value === 'string' && value && value !== this.appServer) {
|
|
283
|
+
this.appServer = value;
|
|
284
|
+
this.logger?.debug(`Session is on application server ${value}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Everything this wire adds to a request of its own accord: the cookies it
|
|
289
|
+
* holds, and the headers that keep it on the server its session lives on.
|
|
290
|
+
*
|
|
291
|
+
* Here rather than on the connection because they are HTTP's — a cookie jar
|
|
292
|
+
* and a dispatcher to stay bound to — and a connection that threaded them
|
|
293
|
+
* would be threading them for every wire, including one that has neither.
|
|
294
|
+
*
|
|
295
|
+
* MERGED with whatever the caller set: a SAML session IS the credential's
|
|
296
|
+
* cookie, and replacing it would send the request out unauthenticated while
|
|
297
|
+
* looking like it carried a session.
|
|
298
|
+
*/
|
|
299
|
+
dress(headers) {
|
|
300
|
+
const dressed = {
|
|
301
|
+
...this.affinityHeaders(),
|
|
302
|
+
...headers,
|
|
303
|
+
};
|
|
304
|
+
const merged = (0, cookies_js_1.mergeCookieHeaders)(headers?.Cookie, this.combined ?? undefined);
|
|
305
|
+
if (merged)
|
|
306
|
+
dressed.Cookie = merged;
|
|
307
|
+
return dressed;
|
|
308
|
+
}
|
|
309
|
+
/** A path becomes an address; anything already absolute is left alone. */
|
|
310
|
+
address(url) {
|
|
311
|
+
if (!url.startsWith('/') || !this.options.baseUrl)
|
|
312
|
+
return url;
|
|
313
|
+
const base = this.options.baseUrl.endsWith('/')
|
|
314
|
+
? this.options.baseUrl.slice(0, -1)
|
|
315
|
+
: this.options.baseUrl;
|
|
316
|
+
return `${base}${url}`;
|
|
317
|
+
}
|
|
318
|
+
client() {
|
|
319
|
+
if (!this.instance) {
|
|
320
|
+
// Kept as it was: an explicit opt-IN, so a misread env var cannot quietly
|
|
321
|
+
// turn verification off.
|
|
322
|
+
const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1' ||
|
|
323
|
+
(process.env.TLS_REJECT_UNAUTHORIZED === '1' &&
|
|
324
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0');
|
|
325
|
+
this.logger?.debug(`TLS configuration: rejectUnauthorized=${rejectUnauthorized}`);
|
|
326
|
+
this.instance = axios_1.default.create({
|
|
327
|
+
httpsAgent: new node_https_1.Agent({
|
|
328
|
+
rejectUnauthorized,
|
|
329
|
+
...this.agentOptions(),
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return this.instance;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Nothing to ask for: an HTTP session arrives with the establishing call.
|
|
337
|
+
*
|
|
338
|
+
* Empty rather than absent. "There is no session resource here" is a fact
|
|
339
|
+
* about this wire, and a fact is stated, not left for a caller to discover by
|
|
340
|
+
* checking whether the method exists.
|
|
341
|
+
*/
|
|
342
|
+
async open(_context) { }
|
|
343
|
+
/**
|
|
344
|
+
* Nothing to give back at this level.
|
|
345
|
+
*
|
|
346
|
+
* The two concrete wires override it — the cloud one DELETEs the session
|
|
347
|
+
* resource, the on-prem one sends the platform logoff. A bare HTTP wire has
|
|
348
|
+
* neither, and says so by doing nothing.
|
|
349
|
+
*/
|
|
350
|
+
async close(_context) { }
|
|
351
|
+
/**
|
|
352
|
+
* Throws for a status the request does not admit — by doing nothing, because
|
|
353
|
+
* that is already what axios does, and `AxiosError` already carries
|
|
354
|
+
* `response`. The contract was written to describe this behaviour rather than
|
|
355
|
+
* to add it.
|
|
356
|
+
*/
|
|
357
|
+
async send(request) {
|
|
358
|
+
return this.dispatch(request, this.dress(request.headers));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Send EXACTLY these headers, with nothing of the wire's live state mixed in.
|
|
362
|
+
*
|
|
363
|
+
* For a goodbye, and only for a goodbye. `disconnect()` dispatches the logoff
|
|
364
|
+
* without awaiting it, so the request is still being assembled while the
|
|
365
|
+
* connection is already free to `connect()` again — and by the time it goes
|
|
366
|
+
* out, the jar can hold a different session. Dressing it then merges the LIVE
|
|
367
|
+
* `SAP_SESSIONID` over the snapshot, and `mergeCookieHeaders` lets the later
|
|
368
|
+
* value win on a repeated name, so the goodbye for the old session arrives
|
|
369
|
+
* addressed to the new one and closes it.
|
|
370
|
+
*
|
|
371
|
+
* Reading the cookies synchronously is necessary and was not sufficient: the
|
|
372
|
+
* snapshot survived only until the send path put the jar back on top of it.
|
|
373
|
+
*/
|
|
374
|
+
async sendDetached(request) {
|
|
375
|
+
return this.dispatch(request, { ...request.headers });
|
|
376
|
+
}
|
|
377
|
+
async dispatch(request, headers) {
|
|
378
|
+
const response = await this.client()({
|
|
379
|
+
method: request.method,
|
|
380
|
+
// The connection hands over a PATH; putting a server in front of it is
|
|
381
|
+
// this wire's job. RFC's is to write the same path into the request line
|
|
382
|
+
// as it stands — handed an absolute URL there, SADT_REST_RFC_ENDPOINT
|
|
383
|
+
// dumps with STRING_OFFSET_TOO_LARGE. Neither can be done for both from
|
|
384
|
+
// above, which is why addressing sits here.
|
|
385
|
+
url: this.address(request.url),
|
|
386
|
+
headers,
|
|
387
|
+
...(request.data !== undefined ? { data: request.data } : {}),
|
|
388
|
+
...(request.params !== undefined ? { params: request.params } : {}),
|
|
389
|
+
...(request.timeout !== undefined ? { timeout: request.timeout } : {}),
|
|
390
|
+
...(request.validateStatus !== undefined
|
|
391
|
+
? { validateStatus: request.validateStatus }
|
|
392
|
+
: {}),
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
status: response.status,
|
|
396
|
+
statusText: response.statusText,
|
|
397
|
+
headers: response.headers,
|
|
398
|
+
data: response.data,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
exports.HttpTransport = HttpTransport;
|