@mcp-abap-adt/connection 1.10.1 → 2.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 +758 -0
- package/README.md +42 -11
- package/dist/__tests__/helpers/session.d.ts +15 -0
- package/dist/__tests__/helpers/session.d.ts.map +1 -0
- package/dist/__tests__/helpers/session.js +19 -0
- package/dist/auth/ntlm.d.ts +15 -0
- package/dist/auth/ntlm.d.ts.map +1 -1
- package/dist/auth/ntlm.js +38 -0
- package/dist/connection/AbstractAbapConnection.d.ts +163 -11
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +351 -14
- package/dist/connection/BaseAbapConnection.d.ts +5 -1
- package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
- package/dist/connection/BaseAbapConnection.js +11 -1
- package/dist/connection/CertificateAbapConnection.d.ts +5 -1
- package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
- package/dist/connection/CertificateAbapConnection.js +11 -1
- package/dist/connection/JwtAbapConnection.d.ts +3 -2
- package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
- package/dist/connection/JwtAbapConnection.js +19 -8
- package/dist/connection/KerberosAbapConnection.d.ts +5 -1
- package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
- package/dist/connection/KerberosAbapConnection.js +54 -3
- package/dist/connection/SamlAbapConnection.d.ts +5 -1
- package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
- package/dist/connection/SamlAbapConnection.js +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/session/SessionLifecycle.d.ts +131 -0
- package/dist/session/SessionLifecycle.d.ts.map +1 -0
- package/dist/session/SessionLifecycle.js +301 -0
- package/docs/INDEX.md +106 -0
- package/docs/INSTALLATION.md +304 -0
- package/docs/JWT_AUTH_TOOLS.md +142 -0
- package/docs/MIGRATION-2.0.md +125 -0
- package/docs/SCOPE.md +44 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +121 -0
- package/docs/USAGE.md +749 -0
- package/examples/README.md +112 -0
- package/examples/basic-connection.js +55 -0
- package/examples/jwt-with-token-refresh.js +87 -0
- package/examples/saml-connection.js +52 -0
- package/examples/websocket-transport.js +87 -0
- package/package.json +11 -4
|
@@ -38,11 +38,24 @@ const node_crypto_1 = require("node:crypto");
|
|
|
38
38
|
const node_https_1 = require("node:https");
|
|
39
39
|
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
40
40
|
const axios_1 = __importStar(require("axios"));
|
|
41
|
+
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
41
42
|
const timeouts_js_1 = require("../utils/timeouts.js");
|
|
42
43
|
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
44
|
+
/**
|
|
45
|
+
* Declares the capabilities explicitly rather than satisfying them by accident.
|
|
46
|
+
* `AbapConnection` is the base contract every transport honours; these two are
|
|
47
|
+
* the HTTP session's own, and naming them means a signature that drifts from
|
|
48
|
+
* the published contract fails to compile here instead of at the consumer.
|
|
49
|
+
*/
|
|
43
50
|
class AbstractAbapConnection {
|
|
44
51
|
config;
|
|
45
52
|
logger;
|
|
53
|
+
/**
|
|
54
|
+
* Owns session state, admission and teardown ordering. Composed rather than
|
|
55
|
+
* inherited: RfcAbapConnection implements the interface directly, so the two
|
|
56
|
+
* transports share this unit instead of a base class.
|
|
57
|
+
*/
|
|
58
|
+
lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
|
|
46
59
|
axiosInstance = null;
|
|
47
60
|
csrfToken = null;
|
|
48
61
|
cookies = null;
|
|
@@ -161,7 +174,259 @@ class AbstractAbapConnection {
|
|
|
161
174
|
getConfig() {
|
|
162
175
|
return this.config;
|
|
163
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Establishes the session, once, under the lifecycle.
|
|
179
|
+
*
|
|
180
|
+
* Idempotent, and concurrent callers share one establishment: the transition
|
|
181
|
+
* joins the tail of its own kind. A teardown requested while establishment
|
|
182
|
+
* was in flight means the caller asked to stop, so usability is NOT published
|
|
183
|
+
* and what was established is released — otherwise a slow connect would hand
|
|
184
|
+
* back a session someone had already discarded.
|
|
185
|
+
*/
|
|
186
|
+
async connect() {
|
|
187
|
+
// Captured HERE, not inside the transition: the callback runs when this
|
|
188
|
+
// reaches the front of the queue, by which time a teardown the caller
|
|
189
|
+
// requested afterwards has already bumped the epoch — and comparing it
|
|
190
|
+
// against itself would let the connect publish a session the caller had
|
|
191
|
+
// asked to stop. The baseline is "when the caller asked to connect".
|
|
192
|
+
const baselineEpoch = this.lifecycle.teardownEpoch;
|
|
193
|
+
await this.lifecycle.transition('connect', async () => {
|
|
194
|
+
if (this.lifecycle.connected)
|
|
195
|
+
return;
|
|
196
|
+
await this.establishAndCommit(baselineEpoch);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Tears the session down. Never throws: the report says what did not finish
|
|
201
|
+
* rather than failing. Sends no ADT session-close — see the design's D2.
|
|
202
|
+
*/
|
|
203
|
+
async disconnect() {
|
|
204
|
+
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
|
|
205
|
+
// The report travels THROUGH the transition, so a joining caller gets the
|
|
206
|
+
// same one. Building it outside would leave every caller but the first
|
|
207
|
+
// reporting an empty teardown — the abandoned locks reported to nobody.
|
|
208
|
+
return this.lifecycle.transition('disconnect', async () => {
|
|
209
|
+
const drained = await this.lifecycle.drain();
|
|
210
|
+
this.clearSessionState();
|
|
211
|
+
this.lifecycle.markDisconnected();
|
|
212
|
+
return {
|
|
213
|
+
abandonedWindows: drained.abandonedWindows,
|
|
214
|
+
releasePending: false,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
isConnected() {
|
|
219
|
+
return this.lifecycle.connected;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Fingerprint of the SAP-side session, or null when none is known.
|
|
223
|
+
*
|
|
224
|
+
* `null` is NOT a statement about the connection. Two situations produce it:
|
|
225
|
+
* no session exists, or the connection is live over a server that issued no
|
|
226
|
+
* session cookie. Use {@link isConnected} for connection state.
|
|
227
|
+
*
|
|
228
|
+
* It follows that null → non-null is not a replacement but an identity being
|
|
229
|
+
* learned; only a CHANGED value means the session was replaced.
|
|
230
|
+
*/
|
|
231
|
+
getSessionIdentity() {
|
|
232
|
+
return this.lifecycle.identity;
|
|
233
|
+
}
|
|
234
|
+
/** Opens a lock window. See the design: a lock outlives its request. */
|
|
235
|
+
beginWindow(label) {
|
|
236
|
+
return this.lifecycle.beginWindow(label);
|
|
237
|
+
}
|
|
238
|
+
endWindow(token) {
|
|
239
|
+
this.lifecycle.endWindow(token);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Discards the session at a caller's request: cancels queued recoveries and
|
|
243
|
+
* queues the cleanup rather than tearing down under a live request.
|
|
244
|
+
*/
|
|
164
245
|
reset() {
|
|
246
|
+
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
|
|
247
|
+
void this.lifecycle.transition('cleanup', async () => {
|
|
248
|
+
await this.lifecycle.drain();
|
|
249
|
+
this.clearSessionState();
|
|
250
|
+
this.lifecycle.markDisconnected();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Re-establishes the session for a request that is recovering from a
|
|
255
|
+
* credential renewal, then lets that request retry.
|
|
256
|
+
*
|
|
257
|
+
* Runs as its own `recover` transition, which never joins another: each
|
|
258
|
+
* recovery carries the baseline of its own request. It yields to a caller's
|
|
259
|
+
* teardown — if the epoch moved since `baselineEpoch`, someone asked to stop
|
|
260
|
+
* while this was being prepared, and a retry must not resurrect a session
|
|
261
|
+
* they discarded.
|
|
262
|
+
*
|
|
263
|
+
* The transition queues behind the cleanup that the renewal itself raised, so
|
|
264
|
+
* it never re-establishes on top of stale transport state.
|
|
265
|
+
*/
|
|
266
|
+
async recoverSession(baselineEpoch) {
|
|
267
|
+
await this.lifecycle.transition('recover', async () => {
|
|
268
|
+
await this.establishAndCommit(baselineEpoch);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Establishes a session and publishes it — but only if nobody asked to stop
|
|
273
|
+
* meanwhile.
|
|
274
|
+
*
|
|
275
|
+
* The epoch is checked BEFORE, so a teardown already requested costs no round
|
|
276
|
+
* trip, and AFTER, because establishment takes time and a caller can ask to
|
|
277
|
+
* stop during it. Checking only before is the defect this exists to prevent:
|
|
278
|
+
* markConnected() would then clear the teardown state and hand back a session
|
|
279
|
+
* the caller had already discarded.
|
|
280
|
+
*
|
|
281
|
+
* Shared by connect() and recoverSession() rather than written twice —
|
|
282
|
+
* the two drifted apart once already, and a third caller would drift again.
|
|
283
|
+
*/
|
|
284
|
+
async establishAndCommit(baselineEpoch) {
|
|
285
|
+
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
286
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
await this.establishSession();
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
// A failed establishment leaves debris that poisons the next attempt: the
|
|
293
|
+
// 401 that rejected us may still have carried a Set-Cookie, and every
|
|
294
|
+
// subclass treats a cookie as proof that auth is already settled —
|
|
295
|
+
// buildAuthorizationHeader() returns '' once one exists. So the next
|
|
296
|
+
// connect() would go out with NO credentials at all, and be rejected for
|
|
297
|
+
// a reason that has nothing to do with why the first one failed.
|
|
298
|
+
//
|
|
299
|
+
// Safe to clear here, unlike the abandonment path below: establishSession()
|
|
300
|
+
// threw, so no session was published, and admission requires a connected
|
|
301
|
+
// lifecycle — nothing can be in flight over what this drops.
|
|
302
|
+
this.invalidateSession();
|
|
303
|
+
// And the identity with it. The rejecting response was still observed, so
|
|
304
|
+
// its cookie was recorded as a session that had just been established —
|
|
305
|
+
// leaving getSessionIdentity() naming a session that never existed while
|
|
306
|
+
// isConnected() says false. Two answers to one question is worse than
|
|
307
|
+
// either.
|
|
308
|
+
this.lifecycle.markDisconnected();
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
312
|
+
// Abandon WITHOUT clearing: the teardown that bumped the epoch is already
|
|
313
|
+
// queued, and it clears after draining. Clearing here would pull cookies,
|
|
314
|
+
// the CSRF token and the axios instance out from under a request that is
|
|
315
|
+
// still in flight — breaking the guarantee this whole change rests on,
|
|
316
|
+
// from inside the guard meant to protect it.
|
|
317
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
|
|
318
|
+
}
|
|
319
|
+
// establishSession() throws on failure, so reaching here means a session
|
|
320
|
+
// exists. There is no third outcome: no "connected but unusable", no
|
|
321
|
+
// resolved promise over an empty jar.
|
|
322
|
+
this.lifecycle.markConnected(this.sessionFingerprint());
|
|
323
|
+
}
|
|
324
|
+
/** The teardown epoch, for a recovery to capture before it starts. */
|
|
325
|
+
get teardownEpoch() {
|
|
326
|
+
return this.lifecycle.teardownEpoch;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Raises a session-lost teardown from inside request handling.
|
|
330
|
+
*
|
|
331
|
+
* There are exactly three things that can cost us the ABAP session, and they
|
|
332
|
+
* were found one at a time precisely because they were written apart. They go
|
|
333
|
+
* through here so a fourth joins the list instead of inventing its own
|
|
334
|
+
* sequence:
|
|
335
|
+
*
|
|
336
|
+
* - the credential was renewed (the injected auth says so);
|
|
337
|
+
* - the server says the session is gone (a dead-session response);
|
|
338
|
+
* - the tracked cookie changed under us while a lock was held.
|
|
339
|
+
*
|
|
340
|
+
* `internal` origin, so it does not cancel the recovery that raised it, and
|
|
341
|
+
* `sessionLost`, so admission shuts at once and the identity is dropped
|
|
342
|
+
* immediately — a later comparison must see the change, and on a dead session
|
|
343
|
+
* the cookie is unchanged, so only the state can tell.
|
|
344
|
+
*
|
|
345
|
+
* Does not await: it is called from inside a request, and the queued cleanup
|
|
346
|
+
* drains that very request.
|
|
347
|
+
*/
|
|
348
|
+
raiseSessionLost(reason) {
|
|
349
|
+
this.logger?.warn(`Session lost: ${reason}`);
|
|
350
|
+
this.lifecycle.beginTeardown({ origin: 'internal', sessionLost: true });
|
|
351
|
+
void this.lifecycle.transition('cleanup', async () => {
|
|
352
|
+
await this.lifecycle.drain();
|
|
353
|
+
this.clearSessionState();
|
|
354
|
+
this.lifecycle.markDisconnected();
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** The credential-renewal raiser; see raiseSessionLost(). */
|
|
358
|
+
discardSession() {
|
|
359
|
+
this.raiseSessionLost('the credential backing it was renewed');
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Whether an error is this connection's own verdict about the session rather
|
|
363
|
+
* than something the server said about a request.
|
|
364
|
+
*
|
|
365
|
+
* A retry path that swallows one of these and rethrows the original error
|
|
366
|
+
* turns "your lock is dead" back into "your request 403'd", which is the very
|
|
367
|
+
* information the caller needs and the only one it cannot recover itself.
|
|
368
|
+
*/
|
|
369
|
+
isSessionVerdict(error) {
|
|
370
|
+
const code = error?.code;
|
|
371
|
+
return (code === interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED ||
|
|
372
|
+
code === interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED ||
|
|
373
|
+
code === interfaces_1.ADT_SESSION_ERROR.RELEASE_PENDING);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Folds a response into the session state AND acts on what it means, in one
|
|
377
|
+
* step.
|
|
378
|
+
*
|
|
379
|
+
* Never call updateCookiesFromResponse() directly: it MUTATES the fingerprint,
|
|
380
|
+
* so discarding its classification absorbs a replacement silently and every
|
|
381
|
+
* later check reads `unchanged`. That is one call site forgetting, and it
|
|
382
|
+
* happened — on the error path and on every retry response.
|
|
383
|
+
*/
|
|
384
|
+
observeResponse(headers) {
|
|
385
|
+
this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Acts on what a response said about the session identity.
|
|
389
|
+
*
|
|
390
|
+
* A replacement is fatal only while a lock is held — and "a lock is held"
|
|
391
|
+
* means an open window, not `sessionMode`: a mode flag cannot represent
|
|
392
|
+
* windows, another handler can flip it back, and a batch never sets it.
|
|
393
|
+
* With no lock held the same replacement is not a loss: nothing was being
|
|
394
|
+
* held, so the new identity simply becomes the current one.
|
|
395
|
+
*/
|
|
396
|
+
applyIdentityPolicy(classification) {
|
|
397
|
+
if (classification !== 'replaced')
|
|
398
|
+
return;
|
|
399
|
+
if (this.lifecycle.openWindows.length === 0) {
|
|
400
|
+
this.logger?.debug('Session identity changed with no lock held; continuing on the new one');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.raiseSessionLost('the session cookie changed while a lock was held');
|
|
404
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced while a lock was held; the lock handle is dead');
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Whether the server is telling us the session it was given no longer exists.
|
|
408
|
+
*
|
|
409
|
+
* The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
|
|
410
|
+
* with the cookie present — which is why identity comparison cannot see this:
|
|
411
|
+
* the cookie, and therefore the fingerprint, is completely unchanged. The
|
|
412
|
+
* exact match is landscape-specific and is one of the live probes this design
|
|
413
|
+
* still owes.
|
|
414
|
+
*/
|
|
415
|
+
isDeadSessionResponse(error) {
|
|
416
|
+
if (!(error instanceof axios_1.AxiosError) || !error.response)
|
|
417
|
+
return false;
|
|
418
|
+
if (error.response.status !== 400)
|
|
419
|
+
return false;
|
|
420
|
+
const text = [
|
|
421
|
+
error.response.statusText,
|
|
422
|
+
typeof error.response.data === 'string' ? error.response.data : '',
|
|
423
|
+
]
|
|
424
|
+
.join(' ')
|
|
425
|
+
.toLowerCase();
|
|
426
|
+
return text.includes('session not found');
|
|
427
|
+
}
|
|
428
|
+
/** Drops everything that described the session. Not a lifecycle transition. */
|
|
429
|
+
clearSessionState() {
|
|
165
430
|
if (this.axiosInstance) {
|
|
166
431
|
this.axiosInstance.interceptors.request.clear();
|
|
167
432
|
this.axiosInstance.interceptors.response.clear();
|
|
@@ -172,6 +437,23 @@ class AbstractAbapConnection {
|
|
|
172
437
|
this.cookieStore.clear();
|
|
173
438
|
// Note: baseUrl is not reset as it's derived from immutable config
|
|
174
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* The session-bearing cookies, and only those.
|
|
442
|
+
*
|
|
443
|
+
* `sap-XSRF_*` is excluded deliberately: it changes on a token refresh WITHIN
|
|
444
|
+
* the same session, so including it would report an ordinary refresh as a new
|
|
445
|
+
* session and fail exactly where nothing is wrong. `sap-usercontext` is ours,
|
|
446
|
+
* overwritten on every response.
|
|
447
|
+
*/
|
|
448
|
+
sessionFingerprint() {
|
|
449
|
+
const fingerprint = new Map();
|
|
450
|
+
for (const [name, value] of this.cookieStore) {
|
|
451
|
+
if (name.startsWith('SAP_SESSIONID')) {
|
|
452
|
+
fingerprint.set(name, value);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return fingerprint;
|
|
456
|
+
}
|
|
175
457
|
async getBaseUrl() {
|
|
176
458
|
return this.baseUrl;
|
|
177
459
|
}
|
|
@@ -187,6 +469,20 @@ class AbstractAbapConnection {
|
|
|
187
469
|
return headers;
|
|
188
470
|
}
|
|
189
471
|
async makeAdtRequest(options) {
|
|
472
|
+
// Admission first, synchronously, before any await: the check and the
|
|
473
|
+
// count must happen in one step, or a request could be admitted and still
|
|
474
|
+
// be invisible to a teardown draining at that instant. Throws
|
|
475
|
+
// NOT_CONNECTED when the caller never connected, or when a teardown has
|
|
476
|
+
// shut the door.
|
|
477
|
+
const lease = this.lifecycle.admitRequest();
|
|
478
|
+
try {
|
|
479
|
+
return await this.performRequest(options);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
lease.release();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async performRequest(options) {
|
|
190
486
|
const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
|
|
191
487
|
const normalizedMethod = method.toUpperCase();
|
|
192
488
|
// Build full URL: baseUrl + endpoint
|
|
@@ -282,7 +578,7 @@ class AbstractAbapConnection {
|
|
|
282
578
|
});
|
|
283
579
|
try {
|
|
284
580
|
const response = await this.getAxiosInstance()(requestConfig);
|
|
285
|
-
this.
|
|
581
|
+
this.observeResponse(response.headers);
|
|
286
582
|
this.logger?.debug(`Request succeeded with status ${response.status}`, {
|
|
287
583
|
type: 'REQUEST_SUCCESS',
|
|
288
584
|
status: response.status,
|
|
@@ -305,7 +601,17 @@ class AbstractAbapConnection {
|
|
|
305
601
|
typeof error.response.data === 'string'
|
|
306
602
|
? error.response.data.slice(0, 200)
|
|
307
603
|
: JSON.stringify(error.response.data).slice(0, 200);
|
|
308
|
-
this.
|
|
604
|
+
this.observeResponse(error.response.headers);
|
|
605
|
+
}
|
|
606
|
+
// The server telling us the session is gone is invisible to the identity
|
|
607
|
+
// comparison: the cookie, and therefore the fingerprint, is unchanged.
|
|
608
|
+
// Only the state can see it, and it must say so at once — otherwise a
|
|
609
|
+
// later unlockAll() finds a match and unlocks over a dead session.
|
|
610
|
+
if (this.isDeadSessionResponse(error)) {
|
|
611
|
+
this.raiseSessionLost('the server reports the session no longer exists');
|
|
612
|
+
// No internal retry: a blind retry here is what produced further locks
|
|
613
|
+
// in the field. The caller decides.
|
|
614
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session no longer exists; any lock handle from it is dead');
|
|
309
615
|
}
|
|
310
616
|
// Check if this is a network error (connection refused, timeout, DNS, etc.)
|
|
311
617
|
// Don't retry for network errors - these indicate infrastructure/VPN issues
|
|
@@ -357,10 +663,16 @@ class AbstractAbapConnection {
|
|
|
357
663
|
requestHeaders.Cookie = refreshedCookies;
|
|
358
664
|
}
|
|
359
665
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
360
|
-
this.
|
|
666
|
+
this.observeResponse(retryResponse.headers);
|
|
361
667
|
return retryResponse;
|
|
362
668
|
}
|
|
363
669
|
catch (retryError) {
|
|
670
|
+
// A session verdict outranks the error that started the retry: the
|
|
671
|
+
// caller can retry a 403 itself, but it cannot discover that its lock
|
|
672
|
+
// handle is dead from a 403.
|
|
673
|
+
if (this.isSessionVerdict(retryError)) {
|
|
674
|
+
throw retryError;
|
|
675
|
+
}
|
|
364
676
|
this.logger?.debug(`CSRF retry failed; rethrowing original error: ${retryError instanceof Error
|
|
365
677
|
? retryError.message
|
|
366
678
|
: String(retryError)}`);
|
|
@@ -379,7 +691,7 @@ class AbstractAbapConnection {
|
|
|
379
691
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
|
|
380
692
|
requestHeaders.Cookie = this.cookies;
|
|
381
693
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
382
|
-
this.
|
|
694
|
+
this.observeResponse(retryResponse.headers);
|
|
383
695
|
return retryResponse;
|
|
384
696
|
}
|
|
385
697
|
// If no cookies, try to get them via CSRF token fetch
|
|
@@ -391,11 +703,14 @@ class AbstractAbapConnection {
|
|
|
391
703
|
requestHeaders.Cookie = this.cookies;
|
|
392
704
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
|
|
393
705
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
394
|
-
this.
|
|
706
|
+
this.observeResponse(retryResponse.headers);
|
|
395
707
|
return retryResponse;
|
|
396
708
|
}
|
|
397
709
|
}
|
|
398
710
|
catch (csrfError) {
|
|
711
|
+
if (this.isSessionVerdict(csrfError)) {
|
|
712
|
+
throw csrfError;
|
|
713
|
+
}
|
|
399
714
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
|
|
400
715
|
// Fall through to throw original error
|
|
401
716
|
}
|
|
@@ -434,6 +749,13 @@ class AbstractAbapConnection {
|
|
|
434
749
|
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay);
|
|
435
750
|
}
|
|
436
751
|
catch (error) {
|
|
752
|
+
// Third layer with a catch on this path, and the last one that could
|
|
753
|
+
// bury a verdict: falling through to the fallback endpoint would open
|
|
754
|
+
// ANOTHER session, and by then the teardown has cleared the fingerprint
|
|
755
|
+
// so the new one reads as `established` and the loss disappears.
|
|
756
|
+
if (this.isSessionVerdict(error)) {
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
437
759
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
438
760
|
this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
|
|
439
761
|
}
|
|
@@ -482,7 +804,7 @@ class AbstractAbapConnection {
|
|
|
482
804
|
headers,
|
|
483
805
|
timeout: (0, timeouts_js_1.getTimeout)('csrf'),
|
|
484
806
|
});
|
|
485
|
-
this.
|
|
807
|
+
this.observeResponse(response.headers);
|
|
486
808
|
const token = response.headers['x-csrf-token'];
|
|
487
809
|
if (!token) {
|
|
488
810
|
this.logger?.error('No CSRF token in response headers', {
|
|
@@ -496,7 +818,7 @@ class AbstractAbapConnection {
|
|
|
496
818
|
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
497
819
|
}
|
|
498
820
|
if (response.headers['set-cookie']) {
|
|
499
|
-
this.
|
|
821
|
+
this.observeResponse(response.headers);
|
|
500
822
|
if (this.cookies) {
|
|
501
823
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
502
824
|
this.logger?.debug('Cookies extracted from response', {
|
|
@@ -508,11 +830,19 @@ class AbstractAbapConnection {
|
|
|
508
830
|
return token;
|
|
509
831
|
}
|
|
510
832
|
catch (error) {
|
|
833
|
+
// A session verdict is not a failed token fetch and must not be
|
|
834
|
+
// retried into silence: the retry would observe the SAME new session,
|
|
835
|
+
// read it as `unchanged`, and the replacement this raised would be gone
|
|
836
|
+
// for good. It leaves immediately, past the loop and past the caller's
|
|
837
|
+
// recovery.
|
|
838
|
+
if (this.isSessionVerdict(error)) {
|
|
839
|
+
throw error;
|
|
840
|
+
}
|
|
511
841
|
if (error instanceof axios_1.AxiosError) {
|
|
512
842
|
// Always try to extract cookies from error response, even on 401
|
|
513
843
|
// This ensures cookies are available for subsequent requests
|
|
514
844
|
if (error.response?.headers) {
|
|
515
|
-
this.
|
|
845
|
+
this.observeResponse(error.response.headers);
|
|
516
846
|
if (this.cookies) {
|
|
517
847
|
this.logger?.debug('Cookies extracted from error response', {
|
|
518
848
|
status: error.response.status,
|
|
@@ -531,14 +861,14 @@ class AbstractAbapConnection {
|
|
|
531
861
|
this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
|
|
532
862
|
const token = error.response.headers['x-csrf-token'];
|
|
533
863
|
if (token) {
|
|
534
|
-
this.
|
|
864
|
+
this.observeResponse(error.response.headers);
|
|
535
865
|
return token;
|
|
536
866
|
}
|
|
537
867
|
}
|
|
538
868
|
if (error.response?.headers['x-csrf-token']) {
|
|
539
869
|
this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
|
|
540
870
|
const token = error.response.headers['x-csrf-token'];
|
|
541
|
-
this.
|
|
871
|
+
this.observeResponse(error.response.headers);
|
|
542
872
|
return token;
|
|
543
873
|
}
|
|
544
874
|
if (error.response) {
|
|
@@ -597,13 +927,19 @@ class AbstractAbapConnection {
|
|
|
597
927
|
setInitialCookies(cookies) {
|
|
598
928
|
this.cookies = cookies;
|
|
599
929
|
}
|
|
930
|
+
/**
|
|
931
|
+
* Folds a response's cookies into the jar and classifies what that means for
|
|
932
|
+
* the session identity. Returns the classification rather than acting on it:
|
|
933
|
+
* cookie parsing stays free of policy, and no exception fires in the middle
|
|
934
|
+
* of a state update. The caller decides.
|
|
935
|
+
*/
|
|
600
936
|
updateCookiesFromResponse(headers) {
|
|
601
937
|
if (!headers) {
|
|
602
|
-
return;
|
|
938
|
+
return 'unchanged';
|
|
603
939
|
}
|
|
604
940
|
const setCookie = headers['set-cookie'];
|
|
605
941
|
if (!setCookie) {
|
|
606
|
-
return;
|
|
942
|
+
return 'unchanged';
|
|
607
943
|
}
|
|
608
944
|
const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
609
945
|
for (const entry of cookiesArray) {
|
|
@@ -633,16 +969,17 @@ class AbstractAbapConnection {
|
|
|
633
969
|
this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
|
|
634
970
|
}
|
|
635
971
|
if (this.cookieStore.size === 0) {
|
|
636
|
-
return;
|
|
972
|
+
return 'unchanged';
|
|
637
973
|
}
|
|
638
974
|
const combined = Array.from(this.cookieStore.entries())
|
|
639
975
|
.map(([name, value]) => (value ? `${name}=${value}` : name))
|
|
640
976
|
.join('; ');
|
|
641
977
|
if (!combined) {
|
|
642
|
-
return;
|
|
978
|
+
return 'unchanged';
|
|
643
979
|
}
|
|
644
980
|
this.cookies = combined;
|
|
645
981
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
982
|
+
return this.lifecycle.observe(this.sessionFingerprint());
|
|
646
983
|
}
|
|
647
984
|
/**
|
|
648
985
|
* Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
|
|
@@ -12,7 +12,11 @@ export declare class BaseAbapConnection extends AbstractAbapConnection {
|
|
|
12
12
|
* Connect to SAP system with Basic Auth
|
|
13
13
|
* Fetches CSRF token which also establishes session cookies
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Establishes the session for this auth type. Called by
|
|
17
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
18
|
+
*/
|
|
19
|
+
protected establishSession(): Promise<void>;
|
|
16
20
|
protected buildAuthorizationHeader(): string;
|
|
17
21
|
private static validateConfig;
|
|
18
22
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;
|
|
1
|
+
{"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6CjD,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAU5C,OAAO,CAAC,MAAM,CAAC,cAAc;CAmB9B"}
|
|
@@ -15,7 +15,11 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
15
15
|
* Connect to SAP system with Basic Auth
|
|
16
16
|
* Fetches CSRF token which also establishes session cookies
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Establishes the session for this auth type. Called by
|
|
20
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
21
|
+
*/
|
|
22
|
+
async establishSession() {
|
|
19
23
|
const baseUrl = await this.getBaseUrl();
|
|
20
24
|
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
21
25
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
@@ -41,6 +45,12 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
41
45
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies extracted from error response during connect (first 100 chars): ${this.getCookies()?.substring(0, 100)}...`);
|
|
42
46
|
}
|
|
43
47
|
}
|
|
48
|
+
// Rethrow: a resolved connect() must mean a usable session exists. This
|
|
49
|
+
// used to swallow and resolve anyway, deferring establishment to the
|
|
50
|
+
// first request — coherent only while that lazy path existed. Without it,
|
|
51
|
+
// swallowing would leave a connection that reports success, holds
|
|
52
|
+
// nothing, and refuses every request.
|
|
53
|
+
throw error;
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
buildAuthorizationHeader() {
|
|
@@ -14,7 +14,11 @@ export declare class CertificateAbapConnection extends AbstractAbapConnection {
|
|
|
14
14
|
* Loads certificate material and primes the session. MUST be called before the first
|
|
15
15
|
* request — the TLS agent is built lazily and needs the client cert present.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Establishes the session for this auth type. Called by
|
|
19
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
20
|
+
*/
|
|
21
|
+
protected establishSession(): Promise<void>;
|
|
18
22
|
protected getHttpsAgentOptions(): AgentOptions;
|
|
19
23
|
protected buildAuthorizationHeader(): string;
|
|
20
24
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;
|
|
1
|
+
{"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6BjD,SAAS,CAAC,oBAAoB,IAAI,YAAY;IAU9C,SAAS,CAAC,wBAAwB,IAAI,MAAM;CAG7C"}
|
|
@@ -38,7 +38,11 @@ class CertificateAbapConnection extends AbstractAbapConnection_js_1.AbstractAbap
|
|
|
38
38
|
* Loads certificate material and primes the session. MUST be called before the first
|
|
39
39
|
* request — the TLS agent is built lazily and needs the client cert present.
|
|
40
40
|
*/
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Establishes the session for this auth type. Called by
|
|
43
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
44
|
+
*/
|
|
45
|
+
async establishSession() {
|
|
42
46
|
await this.ensureMaterial();
|
|
43
47
|
const baseUrl = await this.getBaseUrl();
|
|
44
48
|
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
@@ -53,6 +57,12 @@ class CertificateAbapConnection extends AbstractAbapConnection_js_1.AbstractAbap
|
|
|
53
57
|
this.logger?.debug(`[DEBUG] CertificateAbapConnection - Cookies extracted from error response during connect (first 100 chars): ${this.getCookies()?.substring(0, 100)}...`);
|
|
54
58
|
}
|
|
55
59
|
}
|
|
60
|
+
// Rethrow: a resolved connect() must mean a usable session exists. This
|
|
61
|
+
// used to swallow and resolve anyway, deferring establishment to the
|
|
62
|
+
// first request — coherent only while that lazy path existed. Without it,
|
|
63
|
+
// swallowing would leave a connection that reports success, holds
|
|
64
|
+
// nothing, and refuses every request.
|
|
65
|
+
throw error;
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
getHttpsAgentOptions() {
|
|
@@ -21,9 +21,10 @@ export declare class JwtAbapConnection extends AbstractAbapConnection {
|
|
|
21
21
|
*/
|
|
22
22
|
private tryRefreshToken;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* Establishes the session for this auth type. Called by
|
|
25
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
25
26
|
*/
|
|
26
|
-
|
|
27
|
+
protected establishSession(): Promise<void>;
|
|
27
28
|
/**
|
|
28
29
|
* Override makeAdtRequest to handle JWT auth errors with automatic token refresh
|
|
29
30
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;;;;;GAMG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;IAC3D,OAAO,CAAC,cAAc,CAAC,CAAkB;IACzC,OAAO,CAAC,YAAY,CAAS;gBAG3B,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,eAAe;IAWlC,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAW5C;;;OAGG;YACW,eAAe;IA0B7B
|
|
1
|
+
{"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;;;;;GAMG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;IAC3D,OAAO,CAAC,cAAc,CAAC,CAAkB;IACzC,OAAO,CAAC,YAAY,CAAS;gBAG3B,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,eAAe;IAWlC,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAW5C;;;OAGG;YACW,eAAe;IA0B7B;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2DjD;;OAEG;IACG,cAAc,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EACnC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAiE9B;;OAEG;cACa,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,UAAU,SAAI,EACd,UAAU,SAAO,GAChB,OAAO,CAAC,MAAM,CAAC;IA2ClB,OAAO,CAAC,MAAM,CAAC,cAAc;CAa9B"}
|