@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,358 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.JwtAbapConnection = void 0;
|
|
4
|
-
const node_async_hooks_1 = require("node:async_hooks");
|
|
5
|
-
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
6
|
-
const axios_1 = require("axios");
|
|
7
|
-
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
8
|
-
const AbstractAbapConnection_js_1 = require("./AbstractAbapConnection.js");
|
|
9
|
-
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
10
|
-
/**
|
|
11
|
-
* Is this worth refreshing a token for?
|
|
12
|
-
*
|
|
13
|
-
* 401 only. A 403 means the server authenticated the caller and refused the
|
|
14
|
-
* action anyway — a new token is the same caller, so refreshing answers a
|
|
15
|
-
* question nobody asked and, worse, used to end with the original error
|
|
16
|
-
* replaced by "JWT token has expired". See issue #30.
|
|
17
|
-
*/
|
|
18
|
-
function isTokenExpiryCandidate(error) {
|
|
19
|
-
return error instanceof axios_1.AxiosError && error.response?.status === 401;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* JWT Authentication connection for SAP BTP Cloud systems
|
|
23
|
-
*
|
|
24
|
-
* Supports automatic token refresh via ITokenRefresher injection:
|
|
25
|
-
* - a **401** triggers a token refresh when a tokenRefresher is available;
|
|
26
|
-
* - without one, or when the refresh does not help, the server's own error is
|
|
27
|
-
* thrown unchanged — status and body intact;
|
|
28
|
-
* - a **403** is never a token problem. It propagates as it arrived, because a
|
|
29
|
-
* new token is the same caller and cannot change a permissions answer.
|
|
30
|
-
*/
|
|
31
|
-
class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnection {
|
|
32
|
-
tokenRefresher;
|
|
33
|
-
currentToken;
|
|
34
|
-
/** Bumped by any token refresh, token-only ones included. */
|
|
35
|
-
tokenGeneration = 0;
|
|
36
|
-
/** Single-flight over the token fetch alone. Touches no session state. */
|
|
37
|
-
tokenRefreshInFlight;
|
|
38
|
-
/**
|
|
39
|
-
* The `tokenGeneration` a completed session recovery was built for. Only
|
|
40
|
-
* `performRenewal` moves it, and only after `recoverSession` resolves.
|
|
41
|
-
*
|
|
42
|
-
* Separate from `tokenGeneration` because a token-only refresh — which
|
|
43
|
-
* `fetchCsrfToken` does, from inside an establishment — bumps that counter
|
|
44
|
-
* without rebuilding anything. Reading it as "the session is settled" made
|
|
45
|
-
* the outer handler retry a request whose session was never rebuilt.
|
|
46
|
-
*/
|
|
47
|
-
recoveredGeneration = 0;
|
|
48
|
-
/**
|
|
49
|
-
* Single-flight over the whole credential renewal, session included.
|
|
50
|
-
*
|
|
51
|
-
* Sharing only the token fetch leaves the expensive half racing: `recover`
|
|
52
|
-
* and `cleanup` never join (`SessionLifecycle.transition`), so two concurrent
|
|
53
|
-
* renewals queue as cleanup A → recover A → cleanup B → recover B, and A's
|
|
54
|
-
* retry meets a session B has just torn down.
|
|
55
|
-
*/
|
|
56
|
-
renewalInFlight;
|
|
57
|
-
/**
|
|
58
|
-
* Per connection, deliberately NOT static. `baseline` is compared against
|
|
59
|
-
* `this.tokenGeneration`, which is instance state — a store shared between
|
|
60
|
-
* instances would let one connection's operation hand its baseline to
|
|
61
|
-
* another's, and the comparison would be between unrelated counters.
|
|
62
|
-
*/
|
|
63
|
-
recoveryScope = new node_async_hooks_1.AsyncLocalStorage();
|
|
64
|
-
constructor(config, logger, sessionId, tokenRefresher) {
|
|
65
|
-
JwtAbapConnection.validateConfig(config);
|
|
66
|
-
super(config, logger || null, sessionId);
|
|
67
|
-
this.tokenRefresher = tokenRefresher;
|
|
68
|
-
if (!config.jwtToken) {
|
|
69
|
-
throw new Error('jwtToken is required for JwtAbapConnection');
|
|
70
|
-
}
|
|
71
|
-
this.currentToken = config.jwtToken;
|
|
72
|
-
}
|
|
73
|
-
buildAuthorizationHeader() {
|
|
74
|
-
// Use currentToken which may have been refreshed
|
|
75
|
-
const tokenPreview = this.currentToken
|
|
76
|
-
? `${this.currentToken.substring(0, 10)}...${this.currentToken.substring(Math.max(0, this.currentToken.length - 4))}`
|
|
77
|
-
: 'null';
|
|
78
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.buildAuthorizationHeader - Using token: ${tokenPreview}`);
|
|
79
|
-
return `Bearer ${this.currentToken}`;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* For the public entry point: this call is its own operation, always.
|
|
83
|
-
*
|
|
84
|
-
* A re-entrant `makeAdtRequest` — from a logger or a refresher callback the
|
|
85
|
-
* connection itself invokes while a scope is live — is a new caller-visible
|
|
86
|
-
* operation. Inheriting there would hand it a baseline from somebody else's
|
|
87
|
-
* refresh, which reads as "already refreshed for me" and skips a refresh it
|
|
88
|
-
* needs.
|
|
89
|
-
*/
|
|
90
|
-
inNewRecoveryScope(fn) {
|
|
91
|
-
const scope = {
|
|
92
|
-
baseline: this.tokenGeneration,
|
|
93
|
-
active: true,
|
|
94
|
-
};
|
|
95
|
-
return this.recoveryScope.run(scope, async () => {
|
|
96
|
-
try {
|
|
97
|
-
return await fn();
|
|
98
|
-
}
|
|
99
|
-
finally {
|
|
100
|
-
scope.active = false;
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
/** For the inner levels: join the operation in progress, or start one. */
|
|
105
|
-
inRecoveryScope(fn) {
|
|
106
|
-
const inherited = this.recoveryScope.getStore();
|
|
107
|
-
// Only a scope that is still running. A store reached through an async
|
|
108
|
-
// resource that outlived its operation is stale, and its baseline describes
|
|
109
|
-
// a credential state that has since moved.
|
|
110
|
-
if (inherited?.active)
|
|
111
|
-
return fn();
|
|
112
|
-
return this.inNewRecoveryScope(fn);
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* The baseline this operation is reasoning from.
|
|
116
|
-
*
|
|
117
|
-
* The `active` check here and the one in `inRecoveryScope` OVERLAP: each
|
|
118
|
-
* compensates for the other, and the stale-context test only fails when both
|
|
119
|
-
* are removed. Kept as two because they answer different questions — "may I
|
|
120
|
-
* join this scope" and "may I trust this baseline" — and a reader who finds
|
|
121
|
-
* one redundant would be deleting half a guarantee.
|
|
122
|
-
*/
|
|
123
|
-
currentBaseline() {
|
|
124
|
-
const scope = this.recoveryScope.getStore();
|
|
125
|
-
// No live scope means a caller reached a handler by a path that does not
|
|
126
|
-
// open one, or through a stale async context. Either way, treat it as its
|
|
127
|
-
// own operation rather than trusting a baseline nobody is standing behind.
|
|
128
|
-
return scope?.active ? scope.baseline : this.tokenGeneration;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Fetch a new token, unless somebody already did for this operation.
|
|
132
|
-
*
|
|
133
|
-
* Single-flighted so two concurrent handlers — including two nested
|
|
134
|
-
* `fetchCsrfToken` calls, which is a level `renewalInFlight` cannot reach —
|
|
135
|
-
* share one network call instead of racing and leaving `currentToken` as
|
|
136
|
-
* whichever settled last.
|
|
137
|
-
*
|
|
138
|
-
* @returns true when the caller may retry.
|
|
139
|
-
*/
|
|
140
|
-
refreshTokenOnce(baseline) {
|
|
141
|
-
if (this.tokenGeneration > baseline)
|
|
142
|
-
return Promise.resolve(true);
|
|
143
|
-
if (this.tokenRefreshInFlight)
|
|
144
|
-
return this.tokenRefreshInFlight;
|
|
145
|
-
if (!this.tokenRefresher) {
|
|
146
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - No tokenRefresher available, cannot refresh token`);
|
|
147
|
-
return Promise.resolve(false);
|
|
148
|
-
}
|
|
149
|
-
// Identity-checked clear, as SessionLifecycle.transition does with its
|
|
150
|
-
// tail: a joiner settling late must not clear a fetch somebody started
|
|
151
|
-
// after it.
|
|
152
|
-
const inFlight = this.performTokenRefresh().finally(() => {
|
|
153
|
-
if (this.tokenRefreshInFlight === inFlight) {
|
|
154
|
-
this.tokenRefreshInFlight = undefined;
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
this.tokenRefreshInFlight = inFlight;
|
|
158
|
-
return inFlight;
|
|
159
|
-
}
|
|
160
|
-
async performTokenRefresh() {
|
|
161
|
-
try {
|
|
162
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - Refreshing token via tokenRefresher...`);
|
|
163
|
-
// biome-ignore lint/style/noNonNullAssertion: refreshTokenOnce checks it
|
|
164
|
-
this.currentToken = await this.tokenRefresher.refreshToken();
|
|
165
|
-
this.tokenGeneration += 1;
|
|
166
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - Token refreshed successfully`);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
this.logger?.error(`[ERROR] JwtAbapConnection - Failed to refresh token: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Renew the credential and the session it belongs to, once, shared.
|
|
176
|
-
*/
|
|
177
|
-
renewCredential(baselineEpoch, baseline) {
|
|
178
|
-
if (this.renewalInFlight)
|
|
179
|
-
return this.renewalInFlight;
|
|
180
|
-
// Identity-checked clear, as SessionLifecycle.transition does with its
|
|
181
|
-
// tail: a joiner settling late must not clear a renewal somebody started
|
|
182
|
-
// after it.
|
|
183
|
-
const inFlight = this.performRenewal(baselineEpoch, baseline).finally(() => {
|
|
184
|
-
if (this.renewalInFlight === inFlight)
|
|
185
|
-
this.renewalInFlight = undefined;
|
|
186
|
-
});
|
|
187
|
-
this.renewalInFlight = inFlight;
|
|
188
|
-
return inFlight;
|
|
189
|
-
}
|
|
190
|
-
async performRenewal(baselineEpoch, baseline) {
|
|
191
|
-
// Shared with the token-only path, and a no-op when the token is already
|
|
192
|
-
// newer than the one that failed — then what is missing is the session, and
|
|
193
|
-
// a second fetch answers a question nobody asked.
|
|
194
|
-
if (!(await this.refreshTokenOnce(baseline)))
|
|
195
|
-
return false;
|
|
196
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - Recovering session after token refresh...`);
|
|
197
|
-
// The renewed credential cannot keep the old ABAP session, so this is a
|
|
198
|
-
// session-lost teardown — internal, or it would cancel the very recovery it
|
|
199
|
-
// is setting up. reset() would be the caller-origin one.
|
|
200
|
-
this.discardSession();
|
|
201
|
-
// Re-establish before retrying: the retry goes through admission, and a
|
|
202
|
-
// discarded session admits nothing.
|
|
203
|
-
await this.recoverSession(baselineEpoch);
|
|
204
|
-
// Only here: a session now exists that was built with this token.
|
|
205
|
-
this.recoveredGeneration = this.tokenGeneration;
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* May this operation retry?
|
|
210
|
-
*
|
|
211
|
-
* The order of the checks is the design, not style.
|
|
212
|
-
*/
|
|
213
|
-
async ensureRecovered(baselineEpoch) {
|
|
214
|
-
// 1. A renewal in flight is joined REGARDLESS of generation. A newer token
|
|
215
|
-
// is no use while the session it belongs to is still being rebuilt:
|
|
216
|
-
// retrying now is how a caller meets a closed admission door.
|
|
217
|
-
//
|
|
218
|
-
// This overlaps check 2 as the counters stand — `recoveredGeneration`
|
|
219
|
-
// only moves after `recoverSession` resolves, so check 2 cannot report a
|
|
220
|
-
// rebuild that has not finished, and removing either guard alone leaves
|
|
221
|
-
// the concurrency test green. Both are kept on purpose: this one states
|
|
222
|
-
// the rule ("never decide anything while a renewal is running") without
|
|
223
|
-
// depending on when some other counter happens to move, and it is what
|
|
224
|
-
// keeps the invariant true if check 2's counter is ever changed.
|
|
225
|
-
if (this.renewalInFlight)
|
|
226
|
-
return this.renewalInFlight;
|
|
227
|
-
const baseline = this.currentBaseline();
|
|
228
|
-
// 2. A full renewal COMPLETED since this operation began — token, and the
|
|
229
|
-
// session built with it. Not `tokenGeneration`: that moves on a
|
|
230
|
-
// token-only refresh, which rebuilds nothing.
|
|
231
|
-
if (this.recoveredGeneration > baseline)
|
|
232
|
-
return true;
|
|
233
|
-
// 3. Nobody has. Renew, and let everyone else join.
|
|
234
|
-
return this.renewCredential(baselineEpoch, baseline);
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Establishes the session for this auth type. Called by
|
|
238
|
-
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
239
|
-
*/
|
|
240
|
-
async establishSession() {
|
|
241
|
-
const baseUrl = await this.getBaseUrl();
|
|
242
|
-
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
243
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
244
|
-
try {
|
|
245
|
-
// Try to get CSRF token (this will also get cookies)
|
|
246
|
-
const token = await this.fetchCsrfToken(discoveryUrl);
|
|
247
|
-
this.setCsrfToken(token);
|
|
248
|
-
this.logger?.debug('Successfully connected to SAP system', {
|
|
249
|
-
hasCsrfToken: !!this.getCsrfToken(),
|
|
250
|
-
hasCookies: !!this.getCookies(),
|
|
251
|
-
cookieLength: this.getCookies()?.length || 0,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
catch (error) {
|
|
255
|
-
if (isTokenExpiryCandidate(error)) {
|
|
256
|
-
this.logger?.error('[ERROR] JwtAbapConnection.establishSession - 401 while establishing; fetchCsrfToken has already refreshed and retried for this');
|
|
257
|
-
}
|
|
258
|
-
throw error;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Override makeAdtRequest to handle JWT auth errors with automatic token refresh
|
|
263
|
-
*/
|
|
264
|
-
async makeAdtRequest(options) {
|
|
265
|
-
// A public call is its own operation, whatever scope it starts in.
|
|
266
|
-
return this.inNewRecoveryScope(() => this.attemptRequest(options));
|
|
267
|
-
}
|
|
268
|
-
async attemptRequest(options) {
|
|
269
|
-
// Captured before the attempt: a recovery asks "has the caller asked to
|
|
270
|
-
// stop since this request began", not since some later bookkeeping step.
|
|
271
|
-
const baselineEpoch = this.teardownEpoch;
|
|
272
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Starting request: ${options.method} ${options.url}`);
|
|
273
|
-
try {
|
|
274
|
-
const response = await super.makeAdtRequest(options);
|
|
275
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Request succeeded: ${response.status}`);
|
|
276
|
-
return response;
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
280
|
-
if (isTokenExpiryCandidate(error)) {
|
|
281
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Got 401, attempting token refresh...`);
|
|
282
|
-
if (await this.ensureRecovered(baselineEpoch)) {
|
|
283
|
-
// A renewal this operation joined was fenced against the STARTER's
|
|
284
|
-
// baseline, not this one's, and establishAndCommit's own epoch checks
|
|
285
|
-
// belong to the establishment that ran. Between the last of them and
|
|
286
|
-
// this retry there is a gap nothing else watches: the connection can
|
|
287
|
-
// be torn down by its caller and made usable again, and a retry would
|
|
288
|
-
// then go out on a session that caller discarded.
|
|
289
|
-
if (this.teardownEpoch !== baselineEpoch) {
|
|
290
|
-
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Retry abandoned: a teardown was requested for this connection');
|
|
291
|
-
}
|
|
292
|
-
try {
|
|
293
|
-
return await super.makeAdtRequest(options);
|
|
294
|
-
}
|
|
295
|
-
catch (retryError) {
|
|
296
|
-
// A 401 that survived a renewal is the case the deleted message
|
|
297
|
-
// was about, and it only shows up here — the log below fires when
|
|
298
|
-
// the renewal never happened, which is a different fact.
|
|
299
|
-
if (isTokenExpiryCandidate(retryError)) {
|
|
300
|
-
this.logger?.error('[ERROR] JwtAbapConnection.makeAdtRequest - 401 persists after a credential renewal; the credential may need re-authentication');
|
|
301
|
-
}
|
|
302
|
-
throw retryError;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
this.logger?.error('[ERROR] JwtAbapConnection.makeAdtRequest - 401 persists and the token could not be refreshed; the credential may need re-authentication');
|
|
306
|
-
}
|
|
307
|
-
throw error;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Override fetchCsrfToken to handle JWT auth errors with automatic token refresh
|
|
312
|
-
*/
|
|
313
|
-
async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
|
|
314
|
-
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
315
|
-
generation) {
|
|
316
|
-
// An inner level: join the operation in progress, or start one when
|
|
317
|
-
// reached directly — a bare connect() is still an operation with a
|
|
318
|
-
// baseline.
|
|
319
|
-
return this.inRecoveryScope(() => this.attemptCsrfToken(url, retryCount, retryDelay, generation));
|
|
320
|
-
}
|
|
321
|
-
async attemptCsrfToken(url, retryCount, retryDelay, generation) {
|
|
322
|
-
try {
|
|
323
|
-
// Try to fetch CSRF token using parent implementation
|
|
324
|
-
return await super.fetchCsrfToken(url, retryCount, retryDelay, generation);
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
// A 401 here may be an expired token; anything else is not ours to
|
|
328
|
-
// interpret — a 403 least of all, since a new token is the same caller.
|
|
329
|
-
if (isTokenExpiryCandidate(error)) {
|
|
330
|
-
if (await this.refreshTokenOnce(this.currentBaseline())) {
|
|
331
|
-
// Retry CSRF token fetch with new token
|
|
332
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection.fetchCsrfToken - Retrying after token refresh...`);
|
|
333
|
-
try {
|
|
334
|
-
return await super.fetchCsrfToken(url, retryCount, retryDelay, generation);
|
|
335
|
-
}
|
|
336
|
-
catch (retryError) {
|
|
337
|
-
if (isTokenExpiryCandidate(retryError)) {
|
|
338
|
-
this.logger?.error('[ERROR] JwtAbapConnection.fetchCsrfToken - 401 persists after a token refresh; the credential may need re-authentication');
|
|
339
|
-
}
|
|
340
|
-
throw retryError;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
this.logger?.error('[ERROR] JwtAbapConnection.fetchCsrfToken - 401 persists and the token could not be refreshed; the credential may need re-authentication');
|
|
344
|
-
}
|
|
345
|
-
// Re-throw other errors
|
|
346
|
-
throw error;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
static validateConfig(config) {
|
|
350
|
-
if (config.authType !== 'jwt') {
|
|
351
|
-
throw new Error(`JWT connection expects authType "jwt", got "${config.authType}"`);
|
|
352
|
-
}
|
|
353
|
-
if (!config.jwtToken) {
|
|
354
|
-
throw new Error('JWT authentication requires SAP_JWT_TOKEN to be provided');
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
exports.JwtAbapConnection = JwtAbapConnection;
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { SapConfig } from '../config/sapConfig.js';
|
|
2
|
-
import type { ILogger } from '../logger.js';
|
|
3
|
-
import { AbstractAbapConnection } from './AbstractAbapConnection.js';
|
|
4
|
-
/** Kerberos / SPNEGO single-leg auth: send Negotiate token, reuse the resulting SAP session cookie. */
|
|
5
|
-
export declare class KerberosAbapConnection extends AbstractAbapConnection {
|
|
6
|
-
private spn;
|
|
7
|
-
private currentToken;
|
|
8
|
-
constructor(config: SapConfig, logger?: ILogger | null, sessionId?: string);
|
|
9
|
-
private static validateConfig;
|
|
10
|
-
/** Generate the SPNEGO token once (single-leg). */
|
|
11
|
-
protected ensureToken(): Promise<void>;
|
|
12
|
-
/**
|
|
13
|
-
* Generates the SPNEGO token and primes the session. MUST be called before the first
|
|
14
|
-
* request — the first request carries the Negotiate header; SAP then issues a session
|
|
15
|
-
* cookie which is reused for subsequent requests.
|
|
16
|
-
*/
|
|
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>;
|
|
22
|
-
protected buildAuthorizationHeader(): string;
|
|
23
|
-
}
|
|
24
|
-
//# sourceMappingURL=KerberosAbapConnection.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"KerberosAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/KerberosAbapConnection.ts"],"names":[],"mappings":"AAOA,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,uGAAuG;AACvG,qBAAa,sBAAuB,SAAQ,sBAAsB;IAChE,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,YAAY,CAAM;gBAEd,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EAAE,SAAS,CAAC,EAAE,MAAM;IAQ1E,OAAO,CAAC,MAAM,CAAC,cAAc;IAa7B,mDAAmD;cACnC,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5C;;;;OAIG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAqFjD,SAAS,CAAC,wBAAwB,IAAI,MAAM;CAS7C"}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.KerberosAbapConnection = void 0;
|
|
4
|
-
const axios_1 = require("axios");
|
|
5
|
-
const kerberosSpnego_js_1 = require("../auth/kerberosSpnego.js");
|
|
6
|
-
const ntlm_js_1 = require("../auth/ntlm.js");
|
|
7
|
-
const AbstractAbapConnection_js_1 = require("./AbstractAbapConnection.js");
|
|
8
|
-
/** Kerberos / SPNEGO single-leg auth: send Negotiate token, reuse the resulting SAP session cookie. */
|
|
9
|
-
class KerberosAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnection {
|
|
10
|
-
spn;
|
|
11
|
-
currentToken = '';
|
|
12
|
-
constructor(config, logger, sessionId) {
|
|
13
|
-
KerberosAbapConnection.validateConfig(config);
|
|
14
|
-
super(config, logger || null, sessionId);
|
|
15
|
-
this.spn =
|
|
16
|
-
config.kerberosSpn ??
|
|
17
|
-
`${config.kerberosService ?? 'HTTP'}@${new URL(config.url).hostname}`;
|
|
18
|
-
}
|
|
19
|
-
static validateConfig(config) {
|
|
20
|
-
if (config.authType !== 'kerberos') {
|
|
21
|
-
throw new Error(`Kerberos connection expects authType "kerberos", got "${config.authType}"`);
|
|
22
|
-
}
|
|
23
|
-
if (config.connectionType === 'rfc') {
|
|
24
|
-
throw new Error('Kerberos auth is not supported with connectionType "rfc".');
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
/** Generate the SPNEGO token once (single-leg). */
|
|
28
|
-
async ensureToken() {
|
|
29
|
-
if (!this.currentToken)
|
|
30
|
-
this.currentToken = await (0, kerberosSpnego_js_1.generateSpnegoToken)(this.spn);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Generates the SPNEGO token and primes the session. MUST be called before the first
|
|
34
|
-
* request — the first request carries the Negotiate header; SAP then issues a session
|
|
35
|
-
* cookie which is reused for subsequent requests.
|
|
36
|
-
*/
|
|
37
|
-
/**
|
|
38
|
-
* Establishes the session for this auth type. Called by
|
|
39
|
-
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
40
|
-
*/
|
|
41
|
-
async establishSession() {
|
|
42
|
-
await this.ensureToken();
|
|
43
|
-
const baseUrl = await this.getBaseUrl();
|
|
44
|
-
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
45
|
-
this.logger?.debug(`[DEBUG] KerberosAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
46
|
-
try {
|
|
47
|
-
// ONE attempt, deliberately — the shared default is 3 retries a second
|
|
48
|
-
// apart, and every one of them corrupts this exchange:
|
|
49
|
-
//
|
|
50
|
-
// - A GSS token is one-shot. Replaying it is meaningless at best, and
|
|
51
|
-
// replay protection may answer it differently than the original.
|
|
52
|
-
// - Worse, a 401 that sets a cookie silences buildAuthorizationHeader()
|
|
53
|
-
// (a cookie carries auth after the first round trip), so the retry
|
|
54
|
-
// goes out with NO Authorization at all and draws the server's plain
|
|
55
|
-
// opening challenge.
|
|
56
|
-
//
|
|
57
|
-
// Either way the classifier below would read the LAST response and name
|
|
58
|
-
// the wrong cause. The retry cannot help here anyway: nothing about a
|
|
59
|
-
// rejected token changes by waiting a second. Retrying a transient
|
|
60
|
-
// network failure is the caller's business now that connect() is
|
|
61
|
-
// explicit, and a fresh connect() mints a fresh token.
|
|
62
|
-
const token = await this.fetchCsrfToken(discoveryUrl, 0, 0);
|
|
63
|
-
this.setCsrfToken(token);
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
// The token went out and was not accepted, so it is spent. Keeping it
|
|
67
|
-
// would make every later connect() replay a token already known to fail.
|
|
68
|
-
this.currentToken = '';
|
|
69
|
-
if (error instanceof axios_1.AxiosError && error.response?.headers) {
|
|
70
|
-
const wwwAuth = error.response.headers['www-authenticate'];
|
|
71
|
-
if ((0, ntlm_js_1.isNtlmChallenge)(wwwAuth)) {
|
|
72
|
-
throw new Error('KerberosAbapConnection: server offered NTLM authentication, which is rejected. ' +
|
|
73
|
-
'Only Kerberos/SPNEGO is supported. Ensure the SAP system accepts Kerberos (SPNEGO) for your user.');
|
|
74
|
-
}
|
|
75
|
-
if (this.getCookies()) {
|
|
76
|
-
this.logger?.debug('[DEBUG] KerberosAbapConnection - cookies captured from error response during connect');
|
|
77
|
-
}
|
|
78
|
-
// A 401 carrying a Negotiate token is a CONTINUATION, not a success.
|
|
79
|
-
//
|
|
80
|
-
// Per RFC 4559 the server is handing back gssapi-data for the client to
|
|
81
|
-
// feed into its GSS context and retry. This client cannot do that:
|
|
82
|
-
// generateSpnegoToken() calls step('') once and discards the context,
|
|
83
|
-
// so there is nothing to continue with. The token it holds is the
|
|
84
|
-
// initial one, and sending it again produces the same 401.
|
|
85
|
-
//
|
|
86
|
-
// So this fails, and says why — resolving here would claim a readiness
|
|
87
|
-
// that does not exist and move the failure to the first real request,
|
|
88
|
-
// where it reads as an unrelated auth error.
|
|
89
|
-
if ((0, ntlm_js_1.isNegotiateContinuation)(wwwAuth)) {
|
|
90
|
-
throw new Error('KerberosAbapConnection: the server continued the SPNEGO exchange ' +
|
|
91
|
-
'(401 with a Negotiate token), which requires feeding that token ' +
|
|
92
|
-
'back into the GSS context and retrying. This client performs a ' +
|
|
93
|
-
'single-leg exchange only — the context is discarded after the ' +
|
|
94
|
-
'first step — so multi-leg SPNEGO is not supported. See ' +
|
|
95
|
-
'https://www.rfc-editor.org/rfc/rfc4559');
|
|
96
|
-
}
|
|
97
|
-
// A BARE Negotiate is a different problem with a different fix: the
|
|
98
|
-
// initial Authorization has already gone out, so an empty challenge is
|
|
99
|
-
// the server declining it. No token came back, so there is nothing to
|
|
100
|
-
// step even for a client that supports multi-leg.
|
|
101
|
-
if ((0, ntlm_js_1.isBareNegotiateChallenge)(wwwAuth)) {
|
|
102
|
-
throw new Error('KerberosAbapConnection: the server rejected the SPNEGO token ' +
|
|
103
|
-
'(401 with a bare Negotiate challenge, no token returned). The ' +
|
|
104
|
-
'credentials were not accepted — check the SPN, the ticket ' +
|
|
105
|
-
'(klist / kinit), and that the user is permitted Kerberos logon.');
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
buildAuthorizationHeader() {
|
|
112
|
-
if (this.getCookies())
|
|
113
|
-
return ''; // cookie carries auth after first round-trip
|
|
114
|
-
if (!this.currentToken) {
|
|
115
|
-
throw new Error('KerberosAbapConnection: SPNEGO token not yet available. Call connect() before making requests.');
|
|
116
|
-
}
|
|
117
|
-
return `Negotiate ${this.currentToken}`;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
exports.KerberosAbapConnection = KerberosAbapConnection;
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import type { IAbapRequestOptions, IAdtResponse } from '@mcp-abap-adt/interfaces';
|
|
2
|
-
import type { SapConfig } from '../config/sapConfig.js';
|
|
3
|
-
import type { ILogger } from '../logger.js';
|
|
4
|
-
import type { AbapConnection } from './AbapConnection.js';
|
|
5
|
-
/**
|
|
6
|
-
* RFC-based connection for on-premise SAP systems.
|
|
7
|
-
*
|
|
8
|
-
* Uses @mcp-abap-adt/sap-rfc-lite to call SADT_REST_RFC_ENDPOINT — the same standard SAP FM
|
|
9
|
-
* that Eclipse ADT uses for all on-premise ADT operations via JCo.
|
|
10
|
-
*
|
|
11
|
-
* RFC connections are inherently stateful: one ABAP session persists for the
|
|
12
|
-
* entire connection lifetime. This solves the HTTP 423 "invalid lock handle"
|
|
13
|
-
* problem on legacy systems (BASIS < 7.50) where HTTP stateful sessions
|
|
14
|
-
* are not supported.
|
|
15
|
-
*
|
|
16
|
-
* Connection parameters are derived from the standard ISapConfig.url field:
|
|
17
|
-
* http://saphost:8000 → ashost=saphost, sysnr=00
|
|
18
|
-
*
|
|
19
|
-
* Prerequisites:
|
|
20
|
-
* - SAP NW RFC SDK installed on the machine
|
|
21
|
-
* - @mcp-abap-adt/sap-rfc-lite package installed: npm install @mcp-abap-adt/sap-rfc-lite
|
|
22
|
-
*/
|
|
23
|
-
export declare class RfcAbapConnection implements AbapConnection {
|
|
24
|
-
private readonly config;
|
|
25
|
-
private readonly logger;
|
|
26
|
-
private rfcClient;
|
|
27
|
-
private readonly sessionId;
|
|
28
|
-
private readonly baseUrl;
|
|
29
|
-
private readonly rfcParams;
|
|
30
|
-
private sessionType;
|
|
31
|
-
private sessionCookie;
|
|
32
|
-
constructor(config: SapConfig, logger?: ILogger | null);
|
|
33
|
-
connect(): Promise<void>;
|
|
34
|
-
getBaseUrl(): Promise<string>;
|
|
35
|
-
getSessionId(): string | null;
|
|
36
|
-
setSessionType(type: 'stateful' | 'stateless'): void;
|
|
37
|
-
makeAdtRequest<T = any, D = any>(options: IAbapRequestOptions): Promise<IAdtResponse<T, D>>;
|
|
38
|
-
/**
|
|
39
|
-
* Reset the connection — for RFC this closes the session.
|
|
40
|
-
* Provides interface compatibility with HTTP connections.
|
|
41
|
-
*/
|
|
42
|
-
reset(): void;
|
|
43
|
-
/**
|
|
44
|
-
* Close the RFC connection and release the ABAP session.
|
|
45
|
-
*/
|
|
46
|
-
close(): Promise<void>;
|
|
47
|
-
private static validateConfig;
|
|
48
|
-
}
|
|
49
|
-
//# sourceMappingURL=RfcAbapConnection.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"RfcAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/RfcAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EACb,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAkG1D;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,iBAAkB,YAAW,cAAc;IASpD,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IATzB,OAAO,CAAC,SAAS,CAA2B;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsB;IAChD,OAAO,CAAC,WAAW,CAAyC;IAC5D,OAAO,CAAC,aAAa,CAAuB;gBAGzB,MAAM,EAAE,SAAS,EACjB,MAAM,GAAE,OAAO,GAAG,IAAW;IAa1C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA8BxB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAInC,YAAY,IAAI,MAAM,GAAG,IAAI;IAI7B,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,GAAG,IAAI;IAU9C,cAAc,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EACnC,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IA8L9B;;;OAGG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B,OAAO,CAAC,MAAM,CAAC,cAAc;CAqB9B"}
|