@mcp-abap-adt/connection 4.0.0 → 6.0.0
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 +407 -1
- package/README.md +239 -53
- package/dist/auth/providers.d.ts +124 -0
- package/dist/auth/providers.d.ts.map +1 -0
- package/dist/auth/providers.js +183 -0
- package/dist/connection/AbstractAbapConnection.d.ts +209 -57
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +459 -457
- package/dist/connection/AdtCloudConnector.d.ts +34 -0
- package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
- package/dist/connection/AdtCloudConnector.js +27 -0
- package/dist/connection/AdtOnPremConnector.d.ts +43 -0
- package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
- package/dist/connection/AdtOnPremConnector.js +28 -0
- package/dist/connection/CloudHttpTransport.d.ts +37 -0
- package/dist/connection/CloudHttpTransport.d.ts.map +1 -0
- package/dist/connection/CloudHttpTransport.js +145 -0
- package/dist/connection/CredentialAbapConnection.d.ts +55 -0
- package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
- package/dist/connection/CredentialAbapConnection.js +128 -0
- 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 +256 -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 +10 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -18
- package/dist/session/SessionLifecycle.d.ts +17 -2
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +17 -2
- package/dist/utils/cookies.d.ts +12 -0
- package/dist/utils/cookies.d.ts.map +1 -0
- package/dist/utils/cookies.js +24 -0
- package/dist/utils/timeouts.d.ts +6 -3
- package/dist/utils/timeouts.d.ts.map +1 -1
- package/dist/utils/timeouts.js +6 -3
- package/docs/INDEX.md +5 -2
- package/docs/INSTALLATION.md +28 -6
- package/docs/JWT_AUTH_TOOLS.md +20 -4
- package/docs/MIGRATION-2.0.md +1 -1
- package/docs/MIGRATION-5.0.md +116 -0
- package/docs/MIGRATION-6.0.md +359 -0
- package/docs/SCOPE.md +1 -1
- package/docs/STATEFUL_SESSION_GUIDE.md +155 -20
- package/docs/USAGE.md +322 -111
- 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 -10
- 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/connection/BaseAbapConnection.d.ts +0 -23
- package/dist/connection/BaseAbapConnection.d.ts.map +0 -1
- package/dist/connection/BaseAbapConnection.js +0 -75
- package/dist/connection/CertificateAbapConnection.d.ts +0 -25
- package/dist/connection/CertificateAbapConnection.d.ts.map +0 -1
- package/dist/connection/CertificateAbapConnection.js +0 -79
- package/dist/connection/JwtAbapConnection.d.ts +0 -115
- package/dist/connection/JwtAbapConnection.d.ts.map +0 -1
- package/dist/connection/JwtAbapConnection.js +0 -358
- package/dist/connection/KerberosAbapConnection.d.ts +0 -24
- package/dist/connection/KerberosAbapConnection.d.ts.map +0 -1
- package/dist/connection/KerberosAbapConnection.js +0 -120
- package/dist/connection/RfcAbapConnection.d.ts +0 -49
- package/dist/connection/RfcAbapConnection.d.ts.map +0 -1
- package/dist/connection/RfcAbapConnection.js +0 -331
- package/dist/connection/SamlAbapConnection.d.ts +0 -25
- package/dist/connection/SamlAbapConnection.d.ts.map +0 -1
- package/dist/connection/SamlAbapConnection.js +0 -75
- package/dist/connection/connectionFactory.d.ts +0 -9
- package/dist/connection/connectionFactory.d.ts.map +0 -1
- package/dist/connection/connectionFactory.js +0 -32
|
@@ -1,54 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
3
|
exports.AbstractAbapConnection = void 0;
|
|
37
4
|
const node_crypto_1 = require("node:crypto");
|
|
38
|
-
const node_https_1 = require("node:https");
|
|
39
5
|
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
40
|
-
const axios_1 = __importStar(require("axios"));
|
|
41
6
|
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
42
7
|
const timeouts_js_1 = require("../utils/timeouts.js");
|
|
43
8
|
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
9
|
+
const IAdtTransport_js_1 = require("./IAdtTransport.js");
|
|
44
10
|
/**
|
|
45
11
|
* Declares the capabilities explicitly rather than satisfying them by accident.
|
|
46
12
|
* `AbapConnection` is the base contract every transport honours; these two are
|
|
47
13
|
* the HTTP session's own, and naming them means a signature that drifts from
|
|
48
14
|
* the published contract fails to compile here instead of at the consumer.
|
|
15
|
+
*
|
|
16
|
+
* **This gives the consumer instruments; it does not decide for it.** `connect()`
|
|
17
|
+
* opens one session and `disconnect()` closes it. How many connections to hold,
|
|
18
|
+
* how long to hold them and when to let go stays with the caller — there are no
|
|
19
|
+
* thresholds here, no pooling and no eviction, because none of that is knowable
|
|
20
|
+
* from inside a single connection. A session this one did not open is not its
|
|
21
|
+
* business: the session limit is per user and the pool is shared, so a SAP GUI
|
|
22
|
+
* logon of the same user sits in the same list.
|
|
23
|
+
*
|
|
24
|
+
* **Nothing the server decides is treated as something to count on.** Whether it
|
|
25
|
+
* issues a session cookie, whether it still holds a session it issued, how many
|
|
26
|
+
* it will tolerate, how fast it answers a logoff — all of that is its own and
|
|
27
|
+
* may differ by system and release. So each is observed and reported, never
|
|
28
|
+
* relied upon: the logoff is best effort under a bound the caller sets, a
|
|
29
|
+
* missing session cookie is a warning rather than a rule, and no code here
|
|
30
|
+
* counts sessions or predicts the next answer from the last one.
|
|
49
31
|
*/
|
|
50
32
|
class AbstractAbapConnection {
|
|
51
33
|
config;
|
|
34
|
+
transport;
|
|
52
35
|
logger;
|
|
53
36
|
/**
|
|
54
37
|
* Owns session state, admission and teardown ordering. Composed rather than
|
|
@@ -56,30 +39,79 @@ class AbstractAbapConnection {
|
|
|
56
39
|
* transports share this unit instead of a base class.
|
|
57
40
|
*/
|
|
58
41
|
lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
|
|
59
|
-
axiosInstance = null;
|
|
60
|
-
csrfToken = null;
|
|
61
|
-
cookies = null;
|
|
62
|
-
cookieStore = new Map();
|
|
63
42
|
baseUrl;
|
|
64
43
|
sessionId = null;
|
|
65
44
|
sessionMode = 'stateless';
|
|
66
|
-
skipSessionType;
|
|
67
45
|
/**
|
|
68
46
|
* When true, requests are treated as part of an uninterruptible critical
|
|
69
47
|
* section (e.g. a lock → modify → unlock chain). In this state a short
|
|
70
|
-
* per-request timeout must NOT abort the request mid-flight
|
|
71
|
-
*
|
|
72
|
-
*
|
|
48
|
+
* per-request timeout must NOT abort the request mid-flight: the abort ends
|
|
49
|
+
* nothing server-side — only the server ends an ABAP session, on an idle
|
|
50
|
+
* timeout this side cannot see — it ends what this side KNOWS. Whether the
|
|
51
|
+
* change was applied becomes unknowable, and the handle `unlock` needs is
|
|
52
|
+
* lost while the lock lives on in that session. While set, makeAdtRequest
|
|
73
53
|
* raises the effective timeout to CRITICAL_SECTION_TIMEOUT (a large ceiling)
|
|
74
54
|
* so the request runs to completion instead of being interrupted.
|
|
75
55
|
*/
|
|
76
56
|
inCriticalSection = false;
|
|
77
57
|
/** Reference count for nested beginCriticalSection()/endCriticalSection() pairs. */
|
|
78
58
|
criticalSectionDepth = 0;
|
|
79
|
-
|
|
59
|
+
/** The default release deadline, validated once at construction. */
|
|
60
|
+
/**
|
|
61
|
+
* The logoff for the session this connection last held, while it is on its
|
|
62
|
+
* way. One, because a connection holds one session: `connect()` opens it and
|
|
63
|
+
* `disconnect()` closes it, and how many connections to run is the caller's
|
|
64
|
+
* business, not something to be tracked here.
|
|
65
|
+
*
|
|
66
|
+
* Carries the session id, not just the promise, so a release still in flight
|
|
67
|
+
* for an EARLIER session is recognised as not being this one's — reusing it
|
|
68
|
+
* was what left the second session of a reconnect never released at all.
|
|
69
|
+
*
|
|
70
|
+
* The id is the `SAP_SESSIONID` value — the ABAP session, the one locks are
|
|
71
|
+
* bound to — never the cookie header: that header also carries `sap-XSRF_*`,
|
|
72
|
+
* which rotates within one and the same session, so comparing headers made a
|
|
73
|
+
* session stop recognising itself after a token refresh.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* How this server opens and gives back a session, decided by asking it rather
|
|
77
|
+
* than by guessing which system it is. Set at establishment; until then the
|
|
78
|
+
* on-prem mechanism, which is the one that needs no resource.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* The application server this session lives on, as the server named it.
|
|
82
|
+
*
|
|
83
|
+
* A session belongs to ONE application server. On a multi-node system a
|
|
84
|
+
* request that lands on another gets another session — and a lock held on the
|
|
85
|
+
* first is then dead through nobody's fault and no inactivity. Eclipse pins
|
|
86
|
+
* itself with these headers; without them every request is a fresh throw of
|
|
87
|
+
* the dice.
|
|
88
|
+
*
|
|
89
|
+
* Learned from `sap-adt-saplb` on a response, sent back as `saplb`. Cleared
|
|
90
|
+
* with the rest of the session state: it names a server for a session that no
|
|
91
|
+
* longer exists.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* Whether the preflight opened a session of its own.
|
|
95
|
+
*
|
|
96
|
+
* Distinct from "there are cookies": a failed establishment often leaves a
|
|
97
|
+
* cookie from the 401 that rejected it, and that is debris, not a session.
|
|
98
|
+
* Only a preflight answered with a session address opened one, and only that
|
|
99
|
+
* is worth saying goodbye to when establishment then fails.
|
|
100
|
+
*/
|
|
101
|
+
constructor(config,
|
|
102
|
+
/**
|
|
103
|
+
* Required, and constructed by the caller.
|
|
104
|
+
*
|
|
105
|
+
* There is no default to fall back to and nothing is worked out from the
|
|
106
|
+
* config: the wire is a fact about the deployment, and a library that
|
|
107
|
+
* picked one would be guessing at the thing this design exists to stop
|
|
108
|
+
* guessing at. It also carries what the caller alone can wire — the
|
|
109
|
+
* credential's TLS material, the client — which is why it arrives built.
|
|
110
|
+
*/
|
|
111
|
+
transport, logger, sessionId) {
|
|
80
112
|
this.config = config;
|
|
113
|
+
this.transport = transport;
|
|
81
114
|
this.logger = logger;
|
|
82
|
-
this.skipSessionType = options?.skipSessionType ?? false;
|
|
83
115
|
// Generate sessionId (used for sap-adt-connection-id header)
|
|
84
116
|
this.sessionId = sessionId || (0, node_crypto_1.randomUUID)();
|
|
85
117
|
// Initialize baseUrl from config (required, will throw if invalid)
|
|
@@ -98,16 +130,12 @@ class AbstractAbapConnection {
|
|
|
98
130
|
* - stateful: SAP maintains session state between requests (locks, transactions)
|
|
99
131
|
* - stateless: Each request is independent
|
|
100
132
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* resulting in HTTP 423 on subsequent PUT requests.
|
|
133
|
+
* Whether the header actually goes out is the WIRE's: a system where the
|
|
134
|
+
* stateful header makes the server store locks in session memory instead of
|
|
135
|
+
* the enqueue table — BASIS 7.40 — is a different deployment, and a
|
|
136
|
+
* deployment is a transport, not a flag on the connection.
|
|
106
137
|
*/
|
|
107
138
|
setSessionType(type) {
|
|
108
|
-
if (this.skipSessionType) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
139
|
this.sessionMode = type;
|
|
112
140
|
this.logger?.debug(`Session type set to: ${type}`, {
|
|
113
141
|
sessionId: this.sessionId?.substring(0, 8),
|
|
@@ -126,9 +154,11 @@ class AbstractAbapConnection {
|
|
|
126
154
|
* in a finally, AFTER unlocking). While in a critical section, a short
|
|
127
155
|
* per-request timeout is not applied — makeAdtRequest uses a large ceiling
|
|
128
156
|
* (CRITICAL_SECTION_TIMEOUT, env SAP_TIMEOUT_CRITICAL) instead — so a slow
|
|
129
|
-
* PUT/activate/unlock is not aborted mid-flight.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
157
|
+
* PUT/activate/unlock is not aborted mid-flight. An abort does not end the
|
|
158
|
+
* ABAP session — that session is the server's, and only the server ends it,
|
|
159
|
+
* on an idle timeout this side cannot influence — it strands the caller:
|
|
160
|
+
* the outcome is unknown and the handle `unlock` needs is gone, while the
|
|
161
|
+
* lock survives inside the ABAP session that owns it.
|
|
132
162
|
*
|
|
133
163
|
* Nesting is reference-counted so nested begin/end pairs are safe.
|
|
134
164
|
*/
|
|
@@ -174,6 +204,27 @@ class AbstractAbapConnection {
|
|
|
174
204
|
getConfig() {
|
|
175
205
|
return this.config;
|
|
176
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Gets the credential ready before anything is sent.
|
|
209
|
+
*
|
|
210
|
+
* A no-op for the auth types whose credential is already in hand — basic
|
|
211
|
+
* builds a header from the configuration, JWT carries a token it was given.
|
|
212
|
+
* It exists for the ones that have to fetch or load theirs, because the
|
|
213
|
+
* preflight now runs BEFORE `establishSession()` and needs a credential to
|
|
214
|
+
* go out with: a certificate connection reads its material there, and
|
|
215
|
+
* without this the preflight throws `certificate material not loaded` while
|
|
216
|
+
* assembling the transport — before a single request is made, on every
|
|
217
|
+
* system, cloud or on-prem.
|
|
218
|
+
*
|
|
219
|
+
* Must be idempotent: `establishSession()` may prepare the same credential
|
|
220
|
+
* again, and does.
|
|
221
|
+
*
|
|
222
|
+
* Kerberos deliberately does NOT implement it. Minting the SPNEGO token this
|
|
223
|
+
* early changes when the exchange happens, and that connection is not
|
|
224
|
+
* production-tested — its preflight fails the way it already did, is caught
|
|
225
|
+
* inside the strategy, and the connection falls back to ICF as before.
|
|
226
|
+
*/
|
|
227
|
+
async prepareCredential() { }
|
|
177
228
|
/**
|
|
178
229
|
* Establishes the session, once, under the lifecycle.
|
|
179
230
|
*
|
|
@@ -193,29 +244,73 @@ class AbstractAbapConnection {
|
|
|
193
244
|
await this.lifecycle.transition('connect', async () => {
|
|
194
245
|
if (this.lifecycle.connected)
|
|
195
246
|
return;
|
|
247
|
+
// The wire gets a session in whatever way it has one: an RFC
|
|
248
|
+
// conversation opened, a cloud session resource asked for, nothing at
|
|
249
|
+
// all on a wire whose session arrives with the establishing call. Which
|
|
250
|
+
// of those it is belongs to the transport the caller handed in.
|
|
251
|
+
await this.transport.open(this.sessionContext());
|
|
196
252
|
await this.establishAndCommit(baselineEpoch);
|
|
197
253
|
});
|
|
198
254
|
}
|
|
199
255
|
/**
|
|
200
256
|
* Tears the session down. Never throws, and always settles.
|
|
201
257
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
258
|
+
* Tells the server the session is no longer needed, then clears the local
|
|
259
|
+
* state. **When the server actually reclaims it is the server's business** —
|
|
260
|
+
* possibly not until the next connect asks it for one — and nothing here
|
|
261
|
+
* waits for that or depends on it. What matters is that the session stops
|
|
262
|
+
* being counted against the user, which dropping the cookie alone does not
|
|
263
|
+
* achieve: the server keeps it until its own timeout, so a process that
|
|
264
|
+
* connects repeatedly leaves one behind every time. Measured on S/4HANA on-prem, 25 connects in a row: with the logoff,
|
|
265
|
+
* 24-25 of them were given a session; without it, 2. A connection that gets
|
|
266
|
+
* no session still answers `200` to a LOCK and hands back a handle the next
|
|
267
|
+
* request cannot use, so the leak surfaces as a half-written object rather
|
|
268
|
+
* than as anything about sessions.
|
|
269
|
+
*
|
|
270
|
+
* **Nothing is waited for.** The logoff is dispatched, not awaited: this
|
|
271
|
+
* method notifies, and whether the server has acted on the notice is not
|
|
272
|
+
* something it reports. Finishing chains and releasing locks stay the
|
|
273
|
+
* caller's to do BEFORE calling — waiting here on a request that carries no
|
|
274
|
+
* timeout by design is what made a teardown unbounded, which blocks every
|
|
275
|
+
* later transition on the serialized tail.
|
|
206
276
|
*
|
|
207
|
-
* Requests already in flight
|
|
208
|
-
*
|
|
209
|
-
*
|
|
277
|
+
* **Requests already in flight are not waited for, and the logoff ends the
|
|
278
|
+
* session they are running on** — so they will start failing against a
|
|
279
|
+
* session that no longer exists. That is the caller having asked to
|
|
280
|
+
* disconnect, not a race, and it is a change from the version that only
|
|
281
|
+
* dropped the cookie. Generation fencing (see `SessionLifecycle.isCurrent`)
|
|
282
|
+
* keeps their results from reaching this connection either way.
|
|
210
283
|
*
|
|
211
|
-
*
|
|
284
|
+
* **Nothing to wait for.** This TELLS the server the session is finished and
|
|
285
|
+
* does not act on the answer — whether and when the session is freed is the
|
|
286
|
+
* server's affair. Waiting for a reply nobody reads was also the one thing
|
|
287
|
+
* that could make a teardown unbounded, since the goodbye carries no request
|
|
288
|
+
* timeout by design.
|
|
212
289
|
*/
|
|
213
290
|
async disconnect() {
|
|
291
|
+
// Nothing here throws. This method's place is a `finally` — a connection
|
|
292
|
+
// that was connected must be disconnected — and an exception raised there
|
|
293
|
+
// replaces the error that sent the caller into it.
|
|
214
294
|
// Synchronous, at the call: admission shuts and the generation moves before
|
|
215
295
|
// anything is queued, so a caller who has asked to disconnect cannot have
|
|
216
296
|
// requests still going through while this waits its turn.
|
|
217
297
|
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
|
|
298
|
+
// Captured before the transition and before anything is cleared: a
|
|
299
|
+
// concurrent disconnect JOINS the transition and its callback is never run
|
|
300
|
+
// for the joiner, so a joiner would otherwise learn nothing about what it
|
|
301
|
+
// asked to release.
|
|
302
|
+
const session = this.getSessionIdentity();
|
|
218
303
|
await this.lifecycle.transition('disconnect', async () => {
|
|
304
|
+
// DISPATCHED, not awaited. The goodbye carries no request timeout by
|
|
305
|
+
// design — a server that never answers must not hold a teardown open —
|
|
306
|
+
// and nothing bounds it here either, because there is nothing to bound:
|
|
307
|
+
// awaiting an unbounded request is precisely what would make every
|
|
308
|
+
// teardown unbounded.
|
|
309
|
+
//
|
|
310
|
+
// The context is taken now, while the session is still true: the clear
|
|
311
|
+
// below runs while the close is suspended on its first await.
|
|
312
|
+
const context = this.sessionContext();
|
|
313
|
+
const inFlight = Promise.resolve(this.transport.close(context)).then(() => undefined);
|
|
219
314
|
this.clearSessionState();
|
|
220
315
|
this.lifecycle.markDisconnected();
|
|
221
316
|
});
|
|
@@ -231,22 +326,11 @@ class AbstractAbapConnection {
|
|
|
231
326
|
* session cookie. Use {@link isConnected} for connection state.
|
|
232
327
|
*
|
|
233
328
|
* It follows that null → non-null is not a replacement but an identity being
|
|
234
|
-
* learned; only a CHANGED value means the session
|
|
329
|
+
* learned; only a CHANGED value means the session we had is gone.
|
|
235
330
|
*/
|
|
236
331
|
getSessionIdentity() {
|
|
237
332
|
return this.lifecycle.identity;
|
|
238
333
|
}
|
|
239
|
-
/**
|
|
240
|
-
* Discards the session at a caller's request: cancels queued recoveries and
|
|
241
|
-
* queues the cleanup rather than tearing down under a live request.
|
|
242
|
-
*/
|
|
243
|
-
reset() {
|
|
244
|
-
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
|
|
245
|
-
void this.lifecycle.transition('cleanup', async () => {
|
|
246
|
-
this.clearSessionState();
|
|
247
|
-
this.lifecycle.markDisconnected();
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
334
|
/**
|
|
251
335
|
* Re-establishes the session for a request that is recovering from a
|
|
252
336
|
* credential renewal, then lets that request retry.
|
|
@@ -278,6 +362,36 @@ class AbstractAbapConnection {
|
|
|
278
362
|
* Shared by connect() and recoverSession() rather than written twice —
|
|
279
363
|
* the two drifted apart once already, and a third caller would drift again.
|
|
280
364
|
*/
|
|
365
|
+
/**
|
|
366
|
+
* What a session strategy is given: enough to make one request and to prove
|
|
367
|
+
* the session is ours, and nothing else. It cannot reach session state, so a
|
|
368
|
+
* strategy can neither mark this connection connected nor tear it down.
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* Ask the server to open a session before the establishing call needs one.
|
|
372
|
+
*
|
|
373
|
+
* The strategy is chosen by what the server publishes, not by which system we
|
|
374
|
+
* believe it to be: ABAP Cloud offers a session resource and issues
|
|
375
|
+
* `SAP_SESSIONID` to whoever asks for one — with `x-sap-security-session:
|
|
376
|
+
* create` — while on-prem has no such resource and its session arrives with
|
|
377
|
+
* the establishing request. Believing cloud simply "issues no SAP_SESSIONID"
|
|
378
|
+
* is what happens when nobody asks.
|
|
379
|
+
*/
|
|
380
|
+
/**
|
|
381
|
+
* What a wire needs from this connection to get a session, or give one back.
|
|
382
|
+
*
|
|
383
|
+
* A snapshot in the sense that matters: a close is dispatched without being
|
|
384
|
+
* awaited, so nothing here may read the connection again once the teardown
|
|
385
|
+
* has cleared it.
|
|
386
|
+
*/
|
|
387
|
+
sessionContext() {
|
|
388
|
+
return {
|
|
389
|
+
baseUrl: this.baseUrl,
|
|
390
|
+
authHeaders: () => this.getAuthHeaders(),
|
|
391
|
+
extraHeaders: { 'sap-adt-connection-id': this.getSessionId() ?? '' },
|
|
392
|
+
observe: (headers) => this.observeResponse(headers),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
281
395
|
async establishAndCommit(baselineEpoch) {
|
|
282
396
|
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
283
397
|
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
|
|
@@ -289,6 +403,20 @@ class AbstractAbapConnection {
|
|
|
289
403
|
// makes the new fingerprint `established`, which is what it is.
|
|
290
404
|
this.lifecycle.forgetIdentity();
|
|
291
405
|
try {
|
|
406
|
+
// First, because the preflight below has to be able to authenticate: the
|
|
407
|
+
// credential of a certificate or Kerberos connection is not in hand until
|
|
408
|
+
// it is loaded or minted, and assembling a request without it throws.
|
|
409
|
+
await this.prepareCredential();
|
|
410
|
+
// Before the establishing call, because on a system that has one this is
|
|
411
|
+
// what creates the session the rest of the connection runs in — and the
|
|
412
|
+
// cookies it sets are the ones the establishing call must carry.
|
|
413
|
+
// Forgotten again, because the open was OURS. Establishment is now two
|
|
414
|
+
// requests where it used to be one, and the identity policy answers "did
|
|
415
|
+
// the server move us to a different session while we were working" — a
|
|
416
|
+
// question that has no meaning between two calls we make ourselves to set
|
|
417
|
+
// this session up. The identity that counts is taken at the end, from the
|
|
418
|
+
// cookies we finish with.
|
|
419
|
+
this.lifecycle.forgetIdentity();
|
|
292
420
|
await this.establishSession();
|
|
293
421
|
}
|
|
294
422
|
catch (error) {
|
|
@@ -302,6 +430,26 @@ class AbstractAbapConnection {
|
|
|
302
430
|
// Safe to clear here, unlike the abandonment path below: establishSession()
|
|
303
431
|
// threw, so no session was published, and admission requires a connected
|
|
304
432
|
// lifecycle — nothing can be in flight over what this drops.
|
|
433
|
+
//
|
|
434
|
+
// But say goodbye FIRST. The preflight may already have opened a session
|
|
435
|
+
// — on cloud it does, and the SAP_SESSIONID is in the cookies about to be
|
|
436
|
+
// dropped — and establishing can still fail after it, on a credential the
|
|
437
|
+
// preflight never used. Clearing without telling the server would leak
|
|
438
|
+
// exactly the session this release exists to stop leaking, and leave it
|
|
439
|
+
// unreachable: the connection is not connected, so disconnect() sends
|
|
440
|
+
// nothing, and the cookie that was the only permission to close it is
|
|
441
|
+
// gone. The transport is a snapshot, so this survives the clearing that
|
|
442
|
+
// follows it.
|
|
443
|
+
// Only when the preflight opened one. A cookie left by the 401 that
|
|
444
|
+
// rejected us is debris, not a session, and telling the server we are
|
|
445
|
+
// finished with something we never had sends a request nobody asked for
|
|
446
|
+
// — into the middle of an authentication exchange, in the case that found
|
|
447
|
+
// this.
|
|
448
|
+
// Whether there is anything to say is the wire's to know: a cloud
|
|
449
|
+
// session has an address or it does not, and an on-prem one was
|
|
450
|
+
// established or the cookie is debris from the refusal. The connection
|
|
451
|
+
// asks, and each wire answers by doing nothing when it has nothing.
|
|
452
|
+
const goodbye = this.transport.close(this.sessionContext());
|
|
305
453
|
this.invalidateSession();
|
|
306
454
|
// And the identity with it. The rejecting response was still observed, so
|
|
307
455
|
// its cookie was recorded as a session that had just been established —
|
|
@@ -309,6 +457,10 @@ class AbstractAbapConnection {
|
|
|
309
457
|
// isConnected() says false. Two answers to one question is worse than
|
|
310
458
|
// either.
|
|
311
459
|
this.lifecycle.markDisconnected();
|
|
460
|
+
// Not awaited, for the same reason a teardown does not wait: the caller
|
|
461
|
+
// is owed the establishment error now, not after a round trip nobody is
|
|
462
|
+
// waiting on. closeSession never throws, so nothing here can go unhandled.
|
|
463
|
+
void goodbye;
|
|
312
464
|
throw error;
|
|
313
465
|
}
|
|
314
466
|
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
@@ -319,15 +471,76 @@ class AbstractAbapConnection {
|
|
|
319
471
|
// from inside the guard meant to protect it.
|
|
320
472
|
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
|
|
321
473
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
474
|
+
// The session IS the SAP_SESSIONID the server issued; our own session id is
|
|
475
|
+
// a conversation label we generate and says nothing about what exists on
|
|
476
|
+
// the other side. An empty fingerprint therefore means the server opened no
|
|
477
|
+
// session — on-prem it answers with `sap-XSRF_*` instead once enough
|
|
478
|
+
// sessions are already open for the user — and such a connection still gets
|
|
479
|
+
// `200` for a LOCK and hands back a handle the next request cannot use.
|
|
480
|
+
//
|
|
481
|
+
// SAP_SESSIONID names the ABAP session — the one locks are bound to. Its
|
|
482
|
+
// absence is therefore not a transport problem: the HTTP side is fine, the
|
|
483
|
+
// cookies are here, and stateless requests will work. What is missing is any
|
|
484
|
+
// ABAP session known to this connection, so there is nothing a lock could be
|
|
485
|
+
// bound to and every lock taken over it is dead the moment it is issued.
|
|
486
|
+
//
|
|
487
|
+
// Checked rather than assumed: a connection that got no cookie was held open
|
|
488
|
+
// against an on-prem system and the session list showed nothing for it,
|
|
489
|
+
// while one that got a cookie appeared there.
|
|
490
|
+
//
|
|
491
|
+
// Which is why this refuses to connect rather than warning. There is no
|
|
492
|
+
// count to plan around — the same system allowed 21 sessions one day and
|
|
493
|
+
// refused an eleventh the next — so a caller cannot avoid the condition by
|
|
494
|
+
// being frugal, and the only reliable signal is whether THIS connect got a
|
|
495
|
+
// session. Reported with its cause; recovering is the caller's call, and
|
|
496
|
+
// nothing here retries on anyone's behalf.
|
|
497
|
+
//
|
|
498
|
+
// Reported, not decided on. A session that was not opened is a condition on
|
|
499
|
+
// the server, and what to do about it — wait, retry, release sessions this
|
|
500
|
+
// user still holds, carry on read-only over a fresh connection — depends on
|
|
501
|
+
// things only the caller knows. So it is raised where the caller can catch
|
|
502
|
+
// it, with enough in the message to act on, and nothing is retried here.
|
|
503
|
+
//
|
|
504
|
+
// Every transport, not only basic: splitting by authentication type would
|
|
505
|
+
// encode a guess about cloud ABAP, whose ADT endpoint would not answer the
|
|
506
|
+
// bearer obtainable here, so the question stayed open. If a cloud system
|
|
507
|
+
// turns out to hold sessions without issuing this cookie, this is the rule
|
|
508
|
+
// to revisit — and it will say so loudly rather than fail quietly.
|
|
509
|
+
if (!this.transport.sessionEstablished()) {
|
|
510
|
+
// Goodbye first, for the same reason the catch above does it: the
|
|
511
|
+
// preflight may have opened a session — on cloud it does — and this path
|
|
512
|
+
// is about to drop the cookies that are the only permission to close it.
|
|
513
|
+
// Refusing to connect must not leak the session the refusal is about.
|
|
514
|
+
try {
|
|
515
|
+
void this.transport.close(this.sessionContext());
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
|
|
519
|
+
}
|
|
520
|
+
this.invalidateSession();
|
|
521
|
+
this.lifecycle.forgetIdentity();
|
|
522
|
+
this.lifecycle.markDisconnected();
|
|
523
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, `The server authenticated the request but opened no ABAP session: the ${this.transport.kind} wire reports it is on none, so there is nothing for a lock to be bound to. The wire itself is fine — the request was carried and answered — which is why this is not a transport failure and does not look like one. ` +
|
|
524
|
+
'Stateless reads would still work over it, but a lock, and any write under that lock, is dead the moment it is issued. ' +
|
|
525
|
+
'The usual cause is the system declining to open another session for this user: they are limited per user, shared with every other tool logged on as them, and released either by disconnecting or by their own idle timeout. ' +
|
|
526
|
+
'Whether to wait, retry, or release sessions this user still holds is yours to decide — this library does not retry on your behalf.');
|
|
527
|
+
}
|
|
325
528
|
this.lifecycle.markConnected(this.sessionFingerprint());
|
|
326
529
|
}
|
|
327
530
|
/** The teardown epoch, for a recovery to capture before it starts. */
|
|
328
531
|
get teardownEpoch() {
|
|
329
532
|
return this.lifecycle.teardownEpoch;
|
|
330
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Which session the connection is on now.
|
|
536
|
+
*
|
|
537
|
+
* Moves whenever the session does. A response that comes back carrying an
|
|
538
|
+
* older one belongs to a session that has already been replaced, and must not
|
|
539
|
+
* be acted on as if it said something about the current one.
|
|
540
|
+
*/
|
|
541
|
+
get sessionGeneration() {
|
|
542
|
+
return this.lifecycle.sessionGeneration;
|
|
543
|
+
}
|
|
331
544
|
/**
|
|
332
545
|
* Raises a session-lost teardown from inside request handling.
|
|
333
546
|
*
|
|
@@ -398,7 +611,10 @@ class AbstractAbapConnection {
|
|
|
398
611
|
this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
|
|
399
612
|
return;
|
|
400
613
|
}
|
|
401
|
-
|
|
614
|
+
// The wire folds the response into its own state; what the change MEANS
|
|
615
|
+
// is decided here, because it is a question about the session's lifetime.
|
|
616
|
+
this.transport.ingest(headers);
|
|
617
|
+
this.applyIdentityPolicy(this.lifecycle.observe(this.transport.sessionFingerprint()));
|
|
402
618
|
}
|
|
403
619
|
/**
|
|
404
620
|
* Acts on what a response said about the session identity.
|
|
@@ -420,25 +636,28 @@ class AbstractAbapConnection {
|
|
|
420
636
|
if (classification !== 'replaced')
|
|
421
637
|
return;
|
|
422
638
|
this.raiseSessionLost('the session cookie changed under us');
|
|
423
|
-
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was
|
|
639
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session this connection was using is gone and the requests are now on a different one; anything held against the old session — a lock and any write under it — is dead. ' +
|
|
640
|
+
'The server does not swap sessions on a whim: the usual causes are the session idling out (the timeout is idle-based, so a quiet connection loses it while a busy one does not) or a request landing on a different application server. ' +
|
|
641
|
+
'What to do about it is yours: re-establish and redo the work, or fail the operation. Nothing is retried here.');
|
|
424
642
|
}
|
|
425
643
|
/**
|
|
426
644
|
* Whether the server is telling us the session it was given no longer exists.
|
|
427
645
|
*
|
|
428
|
-
*
|
|
646
|
+
* One on-prem system answered HTTP 400 with "Session not found", answered in ~60 ms
|
|
429
647
|
* with the cookie present — which is why identity comparison cannot see this:
|
|
430
648
|
* the cookie, and therefore the fingerprint, is completely unchanged. The
|
|
431
649
|
* exact match is landscape-specific and is one of the live probes this design
|
|
432
650
|
* still owes.
|
|
433
651
|
*/
|
|
434
652
|
isDeadSessionResponse(error) {
|
|
435
|
-
|
|
653
|
+
const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
|
|
654
|
+
if (!refusal)
|
|
436
655
|
return false;
|
|
437
|
-
if (
|
|
656
|
+
if (refusal.status !== 400)
|
|
438
657
|
return false;
|
|
439
658
|
const text = [
|
|
440
|
-
|
|
441
|
-
typeof
|
|
659
|
+
refusal.statusText,
|
|
660
|
+
typeof refusal.data === 'string' ? refusal.data : '',
|
|
442
661
|
]
|
|
443
662
|
.join(' ')
|
|
444
663
|
.toLowerCase();
|
|
@@ -446,14 +665,11 @@ class AbstractAbapConnection {
|
|
|
446
665
|
}
|
|
447
666
|
/** Drops everything that described the session. Not a lifecycle transition. */
|
|
448
667
|
clearSessionState() {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
this.csrfToken = null;
|
|
455
|
-
this.cookies = null;
|
|
456
|
-
this.cookieStore.clear();
|
|
668
|
+
this.setCsrfToken(null);
|
|
669
|
+
// Cookies, the fingerprint and the application server are the wire's, and
|
|
670
|
+
// it gives them back together — a server named for a session that no
|
|
671
|
+
// longer exists is as stale as the cookie that addressed it.
|
|
672
|
+
this.transport.forgetSession();
|
|
457
673
|
// Note: baseUrl is not reset as it's derived from immutable config
|
|
458
674
|
}
|
|
459
675
|
/**
|
|
@@ -465,13 +681,7 @@ class AbstractAbapConnection {
|
|
|
465
681
|
* overwritten on every response.
|
|
466
682
|
*/
|
|
467
683
|
sessionFingerprint() {
|
|
468
|
-
|
|
469
|
-
for (const [name, value] of this.cookieStore) {
|
|
470
|
-
if (name.startsWith('SAP_SESSIONID')) {
|
|
471
|
-
fingerprint.set(name, value);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return fingerprint;
|
|
684
|
+
return this.transport.sessionFingerprint();
|
|
475
685
|
}
|
|
476
686
|
async getBaseUrl() {
|
|
477
687
|
return this.baseUrl;
|
|
@@ -504,21 +714,22 @@ class AbstractAbapConnection {
|
|
|
504
714
|
async performRequest(options, lease) {
|
|
505
715
|
const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
|
|
506
716
|
const normalizedMethod = method.toUpperCase();
|
|
507
|
-
//
|
|
508
|
-
|
|
717
|
+
// The PATH, not an address. Which server it belongs in front of — or
|
|
718
|
+
// whether it belongs in front of one at all — is the wire's to say.
|
|
719
|
+
const requestUrl = endpoint;
|
|
509
720
|
// Try to ensure CSRF token is available for POST/PUT/DELETE, but don't fail if it can't be fetched
|
|
510
721
|
// The retry logic will handle CSRF token errors automatically
|
|
511
722
|
if (normalizedMethod === 'POST' ||
|
|
512
723
|
normalizedMethod === 'PUT' ||
|
|
513
724
|
normalizedMethod === 'DELETE') {
|
|
514
|
-
if (!this.csrfToken) {
|
|
725
|
+
if (!this.transport.csrfToken()) {
|
|
515
726
|
try {
|
|
516
|
-
await this.
|
|
727
|
+
await this.ensureWireReady();
|
|
517
728
|
}
|
|
518
729
|
catch (error) {
|
|
519
730
|
// If CSRF token can't be fetched upfront, continue anyway
|
|
520
731
|
// The retry logic will handle CSRF token errors automatically
|
|
521
|
-
this.logger?.debug(`
|
|
732
|
+
this.logger?.debug(`Could not fetch CSRF token upfront, will retry on error: ${error instanceof Error ? error.message : String(error)}`);
|
|
522
733
|
}
|
|
523
734
|
}
|
|
524
735
|
}
|
|
@@ -544,20 +755,19 @@ class AbstractAbapConnection {
|
|
|
544
755
|
}
|
|
545
756
|
// Add auth headers (these MUST NOT be overridden)
|
|
546
757
|
Object.assign(requestHeaders, await this.getAuthHeaders());
|
|
758
|
+
// Read once: the wire is asked what it holds, and the same value is what
|
|
759
|
+
// goes on the header.
|
|
760
|
+
const presented = this.transport.csrfToken();
|
|
547
761
|
if ((normalizedMethod === 'POST' ||
|
|
548
762
|
normalizedMethod === 'PUT' ||
|
|
549
763
|
normalizedMethod === 'DELETE') &&
|
|
550
|
-
|
|
551
|
-
requestHeaders['x-csrf-token'] =
|
|
552
|
-
}
|
|
553
|
-
// Add cookies LAST (MUST NOT be overridden by custom headers)
|
|
554
|
-
if (this.cookies) {
|
|
555
|
-
requestHeaders.Cookie = this.cookies;
|
|
556
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
557
|
-
}
|
|
558
|
-
else {
|
|
559
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - NO COOKIES available for this request to ${requestUrl}`);
|
|
764
|
+
presented) {
|
|
765
|
+
requestHeaders['x-csrf-token'] = presented;
|
|
560
766
|
}
|
|
767
|
+
// No cookies and no affinity headers here. Both are the wire's own state,
|
|
768
|
+
// and the wire puts them on the requests it sends — a connection that
|
|
769
|
+
// threaded them would be threading them for every transport, including one
|
|
770
|
+
// that has neither.
|
|
561
771
|
if ((normalizedMethod === 'POST' || normalizedMethod === 'PUT') && data) {
|
|
562
772
|
if (typeof data === 'string' && !requestHeaders['Content-Type']) {
|
|
563
773
|
if (requestUrl.includes('/usageReferences') &&
|
|
@@ -573,8 +783,10 @@ class AbstractAbapConnection {
|
|
|
573
783
|
}
|
|
574
784
|
}
|
|
575
785
|
// Inside an uninterruptible critical section (lock → modify → unlock), a
|
|
576
|
-
// short per-request timeout must not abort the request mid-flight —
|
|
577
|
-
//
|
|
786
|
+
// short per-request timeout must not abort the request mid-flight — not
|
|
787
|
+
// because the abort ends the ABAP session (only the server does that), but
|
|
788
|
+
// because it leaves the outcome unknown and the unlock handle lost while
|
|
789
|
+
// the lock stays held in that session. Raise the effective
|
|
578
790
|
// timeout to the large critical-section ceiling for the whole request
|
|
579
791
|
// (also honoured on the retry paths below, which reuse requestConfig).
|
|
580
792
|
const effectiveTimeout = this.inCriticalSection
|
|
@@ -585,7 +797,9 @@ class AbstractAbapConnection {
|
|
|
585
797
|
url: requestUrl,
|
|
586
798
|
headers: requestHeaders,
|
|
587
799
|
timeout: effectiveTimeout,
|
|
588
|
-
|
|
800
|
+
// `unknown` on the caller's options, a record on the seam: the two
|
|
801
|
+
// transports serialise a query differently and both need the pairs.
|
|
802
|
+
params: params,
|
|
589
803
|
};
|
|
590
804
|
if (data !== undefined) {
|
|
591
805
|
requestConfig.data = data;
|
|
@@ -596,7 +810,7 @@ class AbstractAbapConnection {
|
|
|
596
810
|
method: normalizedMethod,
|
|
597
811
|
});
|
|
598
812
|
try {
|
|
599
|
-
const response = await this.
|
|
813
|
+
const response = await this.transport.send(requestConfig);
|
|
600
814
|
this.observeResponse(response.headers, lease.generation);
|
|
601
815
|
this.logger?.debug(`Request succeeded with status ${response.status}`, {
|
|
602
816
|
type: 'REQUEST_SUCCESS',
|
|
@@ -626,15 +840,18 @@ class AbstractAbapConnection {
|
|
|
626
840
|
message: error instanceof Error ? error.message : String(error),
|
|
627
841
|
url: requestUrl,
|
|
628
842
|
method: normalizedMethod,
|
|
629
|
-
status:
|
|
843
|
+
status: (0, IAdtTransport_js_1.refusalOf)(error)?.status,
|
|
630
844
|
data: undefined,
|
|
631
845
|
};
|
|
632
|
-
|
|
846
|
+
const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
|
|
847
|
+
if (refusal) {
|
|
633
848
|
errorDetails.data =
|
|
634
|
-
typeof
|
|
635
|
-
?
|
|
636
|
-
: JSON.stringify(
|
|
637
|
-
|
|
849
|
+
typeof refusal.data === 'string'
|
|
850
|
+
? refusal.data.slice(0, 200)
|
|
851
|
+
: JSON.stringify(refusal.data).slice(0, 200);
|
|
852
|
+
// Every wire's refusal, not only axios's. A response the connection
|
|
853
|
+
// never observed is a session replacement it never noticed.
|
|
854
|
+
this.observeResponse(refusal.headers, lease.generation);
|
|
638
855
|
}
|
|
639
856
|
// The server telling us the session is gone is invisible to the identity
|
|
640
857
|
// comparison: the cookie, and therefore the fingerprint, is unchanged.
|
|
@@ -660,20 +877,24 @@ class AbstractAbapConnection {
|
|
|
660
877
|
else {
|
|
661
878
|
this.logger?.error(errorDetails.message, errorDetails);
|
|
662
879
|
}
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// discarded before the retry.
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
880
|
+
// The "login-form 401": SAP refused a mutation while we hold a cached CSRF
|
|
881
|
+
// token, so the token and the session it is bound to are dead and must be
|
|
882
|
+
// discarded before the retry.
|
|
883
|
+
//
|
|
884
|
+
// Not keyed on the credential any more. It used to be "basic auth only —
|
|
885
|
+
// JWT/SAML lifecycles are managed elsewhere", and elsewhere was
|
|
886
|
+
// JwtAbapConnection, which is gone. Credential renewal now lives a layer
|
|
887
|
+
// ABOVE this, in CredentialAbapConnection, which wraps the whole request:
|
|
888
|
+
// these retries happen first and it only sees a 401 that survived them.
|
|
889
|
+
// Nothing collides, so nothing needs to be excluded.
|
|
890
|
+
const isCachedTokenStale = (normalizedMethod === 'POST' ||
|
|
891
|
+
normalizedMethod === 'PUT' ||
|
|
892
|
+
normalizedMethod === 'DELETE') &&
|
|
893
|
+
(0, IAdtTransport_js_1.refusalOf)(error)?.status === 401 &&
|
|
673
894
|
this.getCsrfToken() !== null;
|
|
674
895
|
// Retry logic for CSRF token errors (403 with CSRF message) and the
|
|
675
896
|
// login-form 401 pattern.
|
|
676
|
-
if (this.shouldRetryCsrf(error) || isCachedTokenStale) {
|
|
897
|
+
if (this.shouldRetryCsrf(error, normalizedMethod) || isCachedTokenStale) {
|
|
677
898
|
this.logger?.debug(isCachedTokenStale
|
|
678
899
|
? 'Stale CSRF token / SAP session — invalidating and retrying'
|
|
679
900
|
: 'CSRF token validation failed, fetching new token and retrying request', {
|
|
@@ -695,7 +916,7 @@ class AbstractAbapConnection {
|
|
|
695
916
|
if (refreshedCookies) {
|
|
696
917
|
requestHeaders.Cookie = refreshedCookies;
|
|
697
918
|
}
|
|
698
|
-
const retryResponse = await this.
|
|
919
|
+
const retryResponse = await this.transport.send(requestConfig);
|
|
699
920
|
this.observeResponse(retryResponse.headers, lease.generation);
|
|
700
921
|
return retryResponse;
|
|
701
922
|
}
|
|
@@ -712,30 +933,30 @@ class AbstractAbapConnection {
|
|
|
712
933
|
throw error;
|
|
713
934
|
}
|
|
714
935
|
}
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
this.config.authType === 'basic' // Only for basic auth
|
|
721
|
-
) {
|
|
936
|
+
// A 401 on a GET where cookies have since arrived: the first request had
|
|
937
|
+
// none, and the session they name is what the retry needs. Guarded below
|
|
938
|
+
// on actually holding some, so a wire that issues no cookies — RFC —
|
|
939
|
+
// never takes it.
|
|
940
|
+
if ((0, IAdtTransport_js_1.refusalOf)(error)?.status === 401 && normalizedMethod === 'GET') {
|
|
722
941
|
// If we already have cookies from error response, retry immediately
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
942
|
+
const afterError = this.transport.cookies();
|
|
943
|
+
if (afterError) {
|
|
944
|
+
this.logger?.debug(`401 on GET request, retrying with cookies from error response`);
|
|
945
|
+
requestHeaders.Cookie = afterError;
|
|
946
|
+
const retryResponse = await this.transport.send(requestConfig);
|
|
727
947
|
this.observeResponse(retryResponse.headers, lease.generation);
|
|
728
948
|
return retryResponse;
|
|
729
949
|
}
|
|
730
950
|
// If no cookies, try to get them via CSRF token fetch
|
|
731
|
-
this.logger?.debug(`
|
|
951
|
+
this.logger?.debug(`401 on GET request, attempting to get cookies via CSRF token fetch`);
|
|
732
952
|
try {
|
|
733
953
|
// Try to get CSRF token (this will also get cookies)
|
|
734
|
-
this.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
954
|
+
this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation));
|
|
955
|
+
const afterCsrf = this.transport.cookies();
|
|
956
|
+
if (afterCsrf) {
|
|
957
|
+
requestHeaders.Cookie = afterCsrf;
|
|
958
|
+
this.logger?.debug(`Retrying GET request with cookies from CSRF fetch`);
|
|
959
|
+
const retryResponse = await this.transport.send(requestConfig);
|
|
739
960
|
this.observeResponse(retryResponse.headers, lease.generation);
|
|
740
961
|
return retryResponse;
|
|
741
962
|
}
|
|
@@ -744,7 +965,7 @@ class AbstractAbapConnection {
|
|
|
744
965
|
if (this.isSessionVerdict(csrfError)) {
|
|
745
966
|
throw csrfError;
|
|
746
967
|
}
|
|
747
|
-
this.logger?.debug(`
|
|
968
|
+
this.logger?.debug(`Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
|
|
748
969
|
// Fall through to throw original error
|
|
749
970
|
}
|
|
750
971
|
}
|
|
@@ -752,326 +973,108 @@ class AbstractAbapConnection {
|
|
|
752
973
|
}
|
|
753
974
|
}
|
|
754
975
|
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
976
|
+
* Ask the wire to establish itself, and hand back what it earned.
|
|
977
|
+
*
|
|
978
|
+
* The exchange itself is the transport's — it is the HTTP wire that has a
|
|
979
|
+
* token to earn and an endpoint to earn it from, and the RFC wire that has
|
|
980
|
+
* neither. What stays here is the part that is about the SESSION rather than
|
|
981
|
+
* the wire: fencing the answer by generation, and letting the identity policy
|
|
982
|
+
* read what it means.
|
|
757
983
|
*/
|
|
758
|
-
async fetchCsrfToken(
|
|
984
|
+
async fetchCsrfToken(_url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
|
|
759
985
|
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
760
986
|
generation) {
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
];
|
|
780
|
-
}
|
|
781
|
-
let lastError;
|
|
782
|
-
for (const csrfUrl of endpoints) {
|
|
783
|
-
try {
|
|
784
|
-
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
|
|
785
|
-
}
|
|
786
|
-
catch (error) {
|
|
787
|
-
// Third layer with a catch on this path, and the last one that could
|
|
788
|
-
// bury a verdict: falling through to the fallback endpoint would open
|
|
789
|
-
// ANOTHER session, and by then the teardown has cleared the fingerprint
|
|
790
|
-
// so the new one reads as `established` and the loss disappears.
|
|
791
|
-
if (this.isSessionVerdict(error)) {
|
|
792
|
-
throw error;
|
|
793
|
-
}
|
|
794
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
795
|
-
this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
// All endpoints exhausted
|
|
799
|
-
throw lastError ?? new Error('CSRF token fetch failed unexpectedly');
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Fetch CSRF token from a specific endpoint with retries
|
|
803
|
-
*/
|
|
804
|
-
async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
|
|
805
|
-
this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
|
|
806
|
-
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
807
|
-
try {
|
|
808
|
-
if (attempt > 0) {
|
|
809
|
-
this.logger?.debug(`Retry attempt ${attempt}/${retryCount} for CSRF token`);
|
|
810
|
-
}
|
|
811
|
-
const authHeaders = await this.getAuthHeaders();
|
|
812
|
-
const headers = {
|
|
813
|
-
...authHeaders,
|
|
814
|
-
...csrfConfig_js_1.CSRF_CONFIG.REQUIRED_HEADERS,
|
|
815
|
-
};
|
|
816
|
-
// The token fetch belongs to the same ADT conversation as every other
|
|
817
|
-
// request this connection makes. Without the connection id the server
|
|
818
|
-
// sees a caller that merely happens to present our cookies, so a fetch
|
|
819
|
-
// issued while a lock is held reads as a stranger reaching into the
|
|
820
|
-
// session. makeAdtRequest sends this header for all session types; this
|
|
821
|
-
// path must not be the exception.
|
|
822
|
-
if (this.sessionId) {
|
|
823
|
-
headers['sap-adt-connection-id'] = this.sessionId;
|
|
824
|
-
}
|
|
825
|
-
// Always add cookies if available - they are needed for session continuity
|
|
826
|
-
// Even on first attempt, if we have cookies from previous session or error response, use them
|
|
827
|
-
if (this.cookies) {
|
|
828
|
-
headers.Cookie = this.cookies;
|
|
829
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
830
|
-
}
|
|
831
|
-
else {
|
|
832
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - No cookies available for CSRF token request (will get fresh cookies from response)`);
|
|
833
|
-
}
|
|
834
|
-
// Log request details for debugging (only if debug logging is enabled)
|
|
835
|
-
this.logger?.debug(`[DEBUG] CSRF Token Request: url=${csrfUrl}, method=GET, hasAuth=${!!authHeaders.Authorization}, hasClient=${!!authHeaders['X-SAP-Client']}, hasCookies=${!!headers.Cookie}, attempt=${attempt + 1}`);
|
|
836
|
-
const response = await this.getAxiosInstance()({
|
|
837
|
-
method: 'GET',
|
|
838
|
-
url: csrfUrl,
|
|
839
|
-
headers,
|
|
840
|
-
timeout: (0, timeouts_js_1.getTimeout)('csrf'),
|
|
841
|
-
});
|
|
842
|
-
this.observeResponse(response.headers, generation);
|
|
843
|
-
const token = response.headers['x-csrf-token'];
|
|
844
|
-
if (!token) {
|
|
845
|
-
this.logger?.error('No CSRF token in response headers', {
|
|
846
|
-
headers: response.headers,
|
|
847
|
-
status: response.status,
|
|
848
|
-
});
|
|
849
|
-
if (attempt < retryCount) {
|
|
850
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
851
|
-
continue;
|
|
852
|
-
}
|
|
853
|
-
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
854
|
-
}
|
|
855
|
-
if (response.headers['set-cookie']) {
|
|
856
|
-
this.observeResponse(response.headers, generation);
|
|
857
|
-
if (this.cookies) {
|
|
858
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
859
|
-
this.logger?.debug('Cookies extracted from response', {
|
|
860
|
-
cookieLength: this.cookies.length,
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
this.logger?.debug('CSRF token successfully obtained');
|
|
865
|
-
return token;
|
|
866
|
-
}
|
|
867
|
-
catch (error) {
|
|
868
|
-
// A session verdict is not a failed token fetch and must not be
|
|
869
|
-
// retried into silence: the retry would observe the SAME new session,
|
|
870
|
-
// read it as `unchanged`, and the replacement this raised would be gone
|
|
871
|
-
// for good. It leaves immediately, past the loop and past the caller's
|
|
872
|
-
// recovery.
|
|
873
|
-
if (this.isSessionVerdict(error)) {
|
|
874
|
-
throw error;
|
|
875
|
-
}
|
|
876
|
-
if (error instanceof axios_1.AxiosError) {
|
|
877
|
-
// Always try to extract cookies from error response, even on 401
|
|
878
|
-
// This ensures cookies are available for subsequent requests
|
|
879
|
-
if (error.response?.headers) {
|
|
880
|
-
this.observeResponse(error.response.headers, generation);
|
|
881
|
-
if (this.cookies) {
|
|
882
|
-
this.logger?.debug('Cookies extracted from error response', {
|
|
883
|
-
status: error.response.status,
|
|
884
|
-
cookieLength: this.cookies.length,
|
|
885
|
-
});
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
this.logger?.error(`CSRF token error: ${error.message}`, {
|
|
889
|
-
url: csrfUrl,
|
|
890
|
-
status: error.response?.status,
|
|
891
|
-
attempt: attempt + 1,
|
|
892
|
-
maxAttempts: retryCount + 1,
|
|
893
|
-
});
|
|
894
|
-
if (error.response?.status === 405 &&
|
|
895
|
-
error.response?.headers['x-csrf-token']) {
|
|
896
|
-
this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
|
|
897
|
-
const token = error.response.headers['x-csrf-token'];
|
|
898
|
-
if (token) {
|
|
899
|
-
this.observeResponse(error.response.headers, generation);
|
|
900
|
-
return token;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
if (error.response?.headers['x-csrf-token']) {
|
|
904
|
-
this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
|
|
905
|
-
const token = error.response.headers['x-csrf-token'];
|
|
906
|
-
this.observeResponse(error.response.headers, generation);
|
|
907
|
-
return token;
|
|
908
|
-
}
|
|
909
|
-
if (error.response) {
|
|
910
|
-
this.logger?.error('CSRF error details', {
|
|
911
|
-
status: error.response.status,
|
|
912
|
-
statusText: error.response.statusText,
|
|
913
|
-
headers: Object.keys(error.response.headers),
|
|
914
|
-
data: typeof error.response.data === 'string'
|
|
915
|
-
? error.response.data.slice(0, 200)
|
|
916
|
-
: JSON.stringify(error.response.data).slice(0, 200),
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
else if (error.request) {
|
|
920
|
-
this.logger?.error('CSRF request error - no response received', {
|
|
921
|
-
request: error.request.path,
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
else {
|
|
926
|
-
this.logger?.error('CSRF non-axios error', {
|
|
927
|
-
error: error instanceof Error ? error.message : String(error),
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
if (attempt < retryCount) {
|
|
931
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
932
|
-
continue;
|
|
933
|
-
}
|
|
934
|
-
// Preserve original error information, especially AxiosError with response
|
|
935
|
-
if (error instanceof axios_1.AxiosError && error.response) {
|
|
936
|
-
// Re-throw the original AxiosError to preserve response information
|
|
937
|
-
throw error;
|
|
938
|
-
}
|
|
939
|
-
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.FETCH_FAILED(retryCount + 1, error instanceof Error ? error.message : String(error)));
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
throw new Error('CSRF token fetch failed unexpectedly');
|
|
987
|
+
// Dropped first, because this is only ever reached to REPLACE one: the
|
|
988
|
+
// establishment is idempotent and would hand back the very token the
|
|
989
|
+
// caller has just been told is stale.
|
|
990
|
+
this.transport.adoptCsrfToken(null);
|
|
991
|
+
await this.transport.establish({
|
|
992
|
+
baseUrl: this.baseUrl,
|
|
993
|
+
authHeaders: () => this.getAuthHeaders(),
|
|
994
|
+
extraHeaders: { 'sap-adt-connection-id': this.sessionId ?? '' },
|
|
995
|
+
observe: (headers) => this.observeResponse(headers, generation),
|
|
996
|
+
retries: retryCount,
|
|
997
|
+
retryDelayMs: retryDelay,
|
|
998
|
+
timeoutMs: (0, timeouts_js_1.getTimeout)('csrf'),
|
|
999
|
+
isFatal: (error) => this.isSessionVerdict(error),
|
|
1000
|
+
});
|
|
1001
|
+
const token = this.transport.csrfToken();
|
|
1002
|
+
if (!token)
|
|
1003
|
+
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
1004
|
+
return token;
|
|
943
1005
|
}
|
|
944
|
-
/**
|
|
945
|
-
* Get CSRF token (protected for use by subclasses)
|
|
946
|
-
*/
|
|
947
1006
|
getCsrfToken() {
|
|
948
|
-
return this.csrfToken;
|
|
1007
|
+
return this.transport.csrfToken();
|
|
949
1008
|
}
|
|
950
1009
|
/**
|
|
951
1010
|
* Set CSRF token (protected for use by subclasses)
|
|
952
1011
|
*/
|
|
953
1012
|
setCsrfToken(token) {
|
|
954
|
-
|
|
1013
|
+
// A credential that did the exchange itself hands the token to the wire
|
|
1014
|
+
// that will present it.
|
|
1015
|
+
this.transport.adoptCsrfToken(token);
|
|
955
1016
|
}
|
|
956
1017
|
/**
|
|
957
1018
|
* Get cookies (protected for use by subclasses)
|
|
958
1019
|
*/
|
|
959
1020
|
getCookies() {
|
|
960
|
-
return this.cookies;
|
|
961
|
-
}
|
|
962
|
-
setInitialCookies(cookies) {
|
|
963
|
-
this.cookies = cookies;
|
|
1021
|
+
return this.transport.cookies();
|
|
964
1022
|
}
|
|
965
1023
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
1024
|
+
* Seed the wire with cookies the caller already holds — a SAML session, which
|
|
1025
|
+
* IS the credential rather than something a logon call earns.
|
|
1026
|
+
*
|
|
1027
|
+
* Handed over as a response would deliver them, because the wire owns the jar
|
|
1028
|
+
* and how it stores them is its business, not this class's.
|
|
970
1029
|
*/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
const setCookie = headers['set-cookie'];
|
|
976
|
-
if (!setCookie) {
|
|
977
|
-
return 'unchanged';
|
|
978
|
-
}
|
|
979
|
-
const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
980
|
-
for (const entry of cookiesArray) {
|
|
981
|
-
if (typeof entry !== 'string') {
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
const [nameValue] = entry.split(';');
|
|
985
|
-
if (!nameValue) {
|
|
986
|
-
continue;
|
|
987
|
-
}
|
|
988
|
-
const [name, ...rest] = nameValue.split('=');
|
|
989
|
-
if (!name) {
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
const trimmedName = name.trim();
|
|
993
|
-
const trimmedValue = rest.join('=').trim();
|
|
994
|
-
if (!trimmedName) {
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
this.cookieStore.set(trimmedName, trimmedValue);
|
|
998
|
-
}
|
|
999
|
-
// Enforce configured SAP client in sap-usercontext cookie.
|
|
1000
|
-
// SAP may return sap-usercontext=sap-client=<default_client> based on system
|
|
1001
|
-
// default rather than the X-SAP-Client header value, causing requests to be
|
|
1002
|
-
// routed to the wrong client (e.g. a read-only client → 403 on write operations).
|
|
1003
|
-
if (this.config.client) {
|
|
1004
|
-
this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
|
|
1005
|
-
}
|
|
1006
|
-
if (this.cookieStore.size === 0) {
|
|
1007
|
-
return 'unchanged';
|
|
1008
|
-
}
|
|
1009
|
-
const combined = Array.from(this.cookieStore.entries())
|
|
1010
|
-
.map(([name, value]) => (value ? `${name}=${value}` : name))
|
|
1011
|
-
.join('; ');
|
|
1012
|
-
if (!combined) {
|
|
1013
|
-
return 'unchanged';
|
|
1014
|
-
}
|
|
1015
|
-
this.cookies = combined;
|
|
1016
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
1017
|
-
return this.lifecycle.observe(this.sessionFingerprint());
|
|
1030
|
+
setInitialCookies(cookies) {
|
|
1031
|
+
this.transport.ingest({
|
|
1032
|
+
'set-cookie': cookies.split(';').map((entry) => entry.trim()),
|
|
1033
|
+
});
|
|
1018
1034
|
}
|
|
1019
1035
|
/**
|
|
1020
|
-
* Subclasses override to inject extra https.Agent options (e.g. mTLS
|
|
1021
|
-
* The returned options are merged with the base options
|
|
1036
|
+
* Subclasses override to inject extra https.Agent options (e.g. mTLS
|
|
1037
|
+
* cert/key/pfx). The returned options are merged with the base options
|
|
1038
|
+
* (rejectUnauthorized).
|
|
1022
1039
|
*/
|
|
1023
1040
|
getHttpsAgentOptions() {
|
|
1024
1041
|
return {};
|
|
1025
1042
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
// SAP ties the lock handle to the HTTP session (SAP_SESSIONID cookie)
|
|
1044
|
-
if (this.csrfToken) {
|
|
1045
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Reusing existing CSRF token to maintain session`);
|
|
1046
|
-
return;
|
|
1047
|
-
}
|
|
1048
|
-
try {
|
|
1049
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - Fetching NEW CSRF token (will create new SAP session)`);
|
|
1050
|
-
this.csrfToken = await this.fetchCsrfToken(requestUrl);
|
|
1051
|
-
}
|
|
1052
|
-
catch (error) {
|
|
1053
|
-
// fetchCsrfToken handles auth errors
|
|
1054
|
-
// Just re-throw the error with minimal logging to avoid duplicate error messages
|
|
1055
|
-
const errorMsg = error instanceof Error
|
|
1056
|
-
? error.message
|
|
1057
|
-
: csrfConfig_js_1.CSRF_ERROR_MESSAGES.REQUIRED_FOR_MUTATION;
|
|
1058
|
-
// Only log at DEBUG level to avoid duplicate error messages
|
|
1059
|
-
// (fetchCsrfToken already logged the error at ERROR level if auth failed)
|
|
1060
|
-
this.logger?.debug(`[DEBUG] BaseAbapConnection - ensureFreshCsrfToken failed: ${errorMsg}`);
|
|
1061
|
-
throw error;
|
|
1062
|
-
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Make sure the wire is ready to carry a mutation.
|
|
1045
|
+
*
|
|
1046
|
+
* What ready MEANS is the wire's: HTTP holds a CSRF token and returns at once
|
|
1047
|
+
* when it already has one; an RFC conversation has nothing to earn and does
|
|
1048
|
+
* nothing. Demanding a token back was an HTTP assumption, and over RFC it
|
|
1049
|
+
* raised `No CSRF token in response headers` before every write — swallowed
|
|
1050
|
+
* by the caller, but logged as an error and repeated on the next one.
|
|
1051
|
+
*/
|
|
1052
|
+
async ensureWireReady() {
|
|
1053
|
+
await this.transport.establish({
|
|
1054
|
+
baseUrl: this.baseUrl,
|
|
1055
|
+
authHeaders: () => this.getAuthHeaders(),
|
|
1056
|
+
extraHeaders: { 'sap-adt-connection-id': this.sessionId ?? '' },
|
|
1057
|
+
observe: (headers) => this.observeResponse(headers),
|
|
1058
|
+
isFatal: (error) => this.isSessionVerdict(error),
|
|
1059
|
+
});
|
|
1063
1060
|
}
|
|
1064
1061
|
/**
|
|
1065
1062
|
* Clear SAP-side session state when SAP rejects the cached CSRF token + session
|
|
1066
1063
|
* cookies (HTTP 401 on a mutation while a cached token exists). This forces the
|
|
1067
1064
|
* next request path to fetch a fresh token and a fresh SAP_SESSIONID cookie.
|
|
1068
1065
|
*
|
|
1069
|
-
* Distinct from
|
|
1066
|
+
* Distinct from disconnect(): this leaves the axios instance and interceptors
|
|
1067
|
+
* in place, and tells the server nothing — it is a request-level repair, not a
|
|
1068
|
+
* teardown.
|
|
1070
1069
|
*/
|
|
1071
1070
|
invalidateSession() {
|
|
1072
1071
|
this.setCsrfToken(null);
|
|
1073
|
-
|
|
1074
|
-
|
|
1072
|
+
// The wire's state described THAT session: the cookies, the fingerprint
|
|
1073
|
+
// taken from them, and the application server it lived on. Sending the
|
|
1074
|
+
// server name again would pin the next connect — preflight included — to a
|
|
1075
|
+
// server whose session is gone.
|
|
1076
|
+
this.transport.forgetSession();
|
|
1077
|
+
// Everything else that described THAT session. The application server named
|
|
1075
1078
|
// And the tracked identity, because WE discarded the session. Without this
|
|
1076
1079
|
// the cookie that arrives next reads as a foreign replacement — and since a
|
|
1077
1080
|
// replacement is now always fatal, our own deliberate re-authentication
|
|
@@ -1080,28 +1083,27 @@ class AbstractAbapConnection {
|
|
|
1080
1083
|
// is not a session taken from under us.
|
|
1081
1084
|
this.lifecycle.forgetIdentity();
|
|
1082
1085
|
}
|
|
1083
|
-
shouldRetryCsrf(error) {
|
|
1084
|
-
|
|
1086
|
+
shouldRetryCsrf(error, method) {
|
|
1087
|
+
const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
|
|
1088
|
+
if (!refusal) {
|
|
1085
1089
|
return false;
|
|
1086
1090
|
}
|
|
1087
|
-
const responseData =
|
|
1091
|
+
const responseData = refusal.data;
|
|
1088
1092
|
const responseText = typeof responseData === 'string'
|
|
1089
1093
|
? responseData
|
|
1090
1094
|
: JSON.stringify(responseData || '');
|
|
1091
|
-
// Don't retry for JWT auth - refresh logic will handle it
|
|
1092
|
-
if (this.config.authType === 'jwt') {
|
|
1093
|
-
return false;
|
|
1094
|
-
}
|
|
1095
1095
|
// Retry on 403 with CSRF message, or if response mentions CSRF token
|
|
1096
1096
|
// Also retry on 401 for POST/PUT/DELETE if we don't have CSRF token yet (might need to get cookies first)
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1097
|
+
// Handed in rather than read off `error.config`, which is axios's own
|
|
1098
|
+
// record of the request and does not exist on another wire's refusal. The
|
|
1099
|
+
// caller already normalised the method; asking the error for it was asking
|
|
1100
|
+
// the HTTP client.
|
|
1101
|
+
const normalized = method?.toUpperCase();
|
|
1102
|
+
const isPostPutDelete = normalized && ['POST', 'PUT', 'DELETE'].includes(normalized);
|
|
1103
|
+
const needsCsrfToken = !!isPostPutDelete && !this.transport.csrfToken();
|
|
1104
|
+
return ((refusal.status === 403 && responseText.includes('CSRF')) ||
|
|
1103
1105
|
responseText.includes('CSRF token') ||
|
|
1104
|
-
(needsCsrfToken &&
|
|
1106
|
+
(needsCsrfToken && refusal.status === 401));
|
|
1105
1107
|
}
|
|
1106
1108
|
}
|
|
1107
1109
|
exports.AbstractAbapConnection = AbstractAbapConnection;
|