@mcp-abap-adt/connection 3.0.0 → 4.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 +64 -1
- package/README.md +15 -2
- package/dist/connection/BaseAbapConnection.js +1 -1
- package/dist/connection/CertificateAbapConnection.js +1 -1
- package/dist/connection/JwtAbapConnection.d.ts +84 -7
- package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
- package/dist/connection/JwtAbapConnection.js +246 -80
- package/dist/connection/SamlAbapConnection.js +1 -1
- package/docs/INDEX.md +5 -0
- package/docs/MIGRATION-4.0.md +95 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +6 -0
- package/docs/USAGE.md +23 -2
- package/examples/README.md +2 -1
- package/examples/jwt-with-token-refresh.js +11 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.0.0] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
A JWT connection stops answering with an error of its own making. See
|
|
13
|
+
[docs/MIGRATION-4.0.md](docs/MIGRATION-4.0.md).
|
|
14
|
+
|
|
15
|
+
### Breaking
|
|
16
|
+
|
|
17
|
+
- **A 403 no longer triggers a token refresh.** It means the server authenticated the caller and
|
|
18
|
+
refused the action anyway, so a new token is the same caller and cannot change the answer.
|
|
19
|
+
- **`Error('JWT token has expired. Please re-authenticate.')` is gone.** Both a 401 and a 403 were
|
|
20
|
+
reported with it, and the original `AxiosError` was discarded along with the status and the
|
|
21
|
+
body. The server's error now reaches the caller unchanged. Code matching on that message must
|
|
22
|
+
branch on `error.response.status` instead — which it can now do.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The substring guard meant to let permission failures past matched three literal strings and SAP
|
|
27
|
+
sends none of them: the type is `ExceptionResourceNoAuthorization`, not `...NoAccess`, and the
|
|
28
|
+
message reads "not authorized", not "No authorization". The list is deleted rather than
|
|
29
|
+
extended — it enumerated prose, so the next unlisted wording would have been the same defect
|
|
30
|
+
([#30](https://github.com/fr0ster/mcp-abap-connection/issues/30)).
|
|
31
|
+
- A 401 that survives a renewal is now logged at ERROR. The previous log fired only when the
|
|
32
|
+
renewal never happened, so the case the deleted message was actually about passed silently.
|
|
33
|
+
- `establishSession` no longer refreshes or recurses into itself. `fetchCsrfToken` owns recovery
|
|
34
|
+
during establishment; a second refresh at the outer level asked the same refresher the same
|
|
35
|
+
question, and the recursion was unbounded whenever the refresher kept resolving.
|
|
36
|
+
- `JwtAbapConnection.fetchCsrfToken` declared three parameters where the base declares four and
|
|
37
|
+
called `super` with three, silently dropping the `generation` that fences response effects.
|
|
38
|
+
TypeScript accepts that — fewer parameters are assignable to more. Latent rather than live:
|
|
39
|
+
both call sites that pass one are gated on basic auth.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **One credential renewal per caller-visible operation, session included.** Concurrent requests
|
|
44
|
+
meeting the same expired token now share a single token fetch *and* a single session
|
|
45
|
+
re-establishment. Previously each ran its own teardown and recovery — and since `recover` and
|
|
46
|
+
`cleanup` never join, one request's cleanup could tear down the session another had just
|
|
47
|
+
rebuilt, leaving its retry at a closed door.
|
|
48
|
+
- The renewal decision is made against the credential state the operation started with, carried
|
|
49
|
+
in a per-connection `AsyncLocalStorage`. A nested token-only refresh no longer passes for a
|
|
50
|
+
session recovery.
|
|
51
|
+
- A retry is abandoned with `ADT_NOT_CONNECTED` if the connection was torn down between the last
|
|
52
|
+
lifecycle check and the retry itself.
|
|
53
|
+
- `establishSession` takes its CSRF retry defaults from `CSRF_CONFIG` at all four authentication
|
|
54
|
+
types instead of repeating `3, 1000` in each.
|
|
55
|
+
|
|
56
|
+
### Documentation
|
|
57
|
+
|
|
58
|
+
- New [MIGRATION-4.0.md](docs/MIGRATION-4.0.md), linked from the README and the docs index, which
|
|
59
|
+
now has an *Upgrading* section — the 2.0 migration guide was in the tree but linked from
|
|
60
|
+
nowhere but a directory listing.
|
|
61
|
+
- `USAGE.md` gains the classification table; `STATEFUL_SESSION_GUIDE.md` notes that a 401-driven
|
|
62
|
+
refresh replaces the SAP session and surfaces as `ADT_SESSION_REPLACED` inside a lock window,
|
|
63
|
+
while a 403 tears down nothing; the README and both examples no longer promise a refresh on
|
|
64
|
+
403.
|
|
65
|
+
|
|
66
|
+
### Known
|
|
67
|
+
|
|
68
|
+
- Whether some BTP setup answers an *invalid* token with 403 rather than 401 is unobserved and
|
|
69
|
+
tracked in [#32](https://github.com/fr0ster/mcp-abap-connection/issues/32). Preserving the
|
|
70
|
+
original error is what makes the assumption safe to be wrong about.
|
|
71
|
+
|
|
10
72
|
## [3.0.0] - 2026-08-03
|
|
11
73
|
|
|
12
74
|
Undoes two mistakes from 2.0.0, four days old. Everything else that release
|
|
@@ -786,7 +848,8 @@ const connection = createAbapConnection(config, logger);
|
|
|
786
848
|
- JWT token refresh now properly handles connection errors (401/403 during initial connect)
|
|
787
849
|
- Permission errors (403 with "ExceptionResourceNoAccess") no longer trigger JWT refresh loops
|
|
788
850
|
- Proper separation: base class handles HTTP/session, concrete classes handle auth-specific errors
|
|
789
|
-
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/
|
|
851
|
+
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v4.0.0...HEAD
|
|
852
|
+
[4.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v3.0.0...v4.0.0
|
|
790
853
|
[3.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v2.0.0...v3.0.0
|
|
791
854
|
[2.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.2...v2.0.0
|
|
792
855
|
[1.10.2]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.1...v1.10.2
|
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ This package interacts with external packages **ONLY through interfaces**:
|
|
|
113
113
|
|
|
114
114
|
- 📦 **[Installation Guide](./docs/INSTALLATION.md)** - Setup and installation instructions
|
|
115
115
|
- 📚 **[Usage Guide](./docs/USAGE.md)** - Detailed usage examples and API documentation
|
|
116
|
+
- 🚚 **[Migration to 4.0.0](./docs/MIGRATION-4.0.md)** - a 401 refreshes the token, a 403 reaches you with the server's message; the synthesised "JWT token has expired" is gone
|
|
116
117
|
- 🚚 **[Migration: the explicit session lifecycle](./docs/MIGRATION-2.0.md)** - `connect()` is now required; start here if you are coming from 1.x
|
|
117
118
|
- 💡 **[Examples](./examples/)** - Working code examples
|
|
118
119
|
|
|
@@ -220,7 +221,7 @@ const response = await connection.makeAdtRequest({
|
|
|
220
221
|
|
|
221
222
|
### Cloud Usage with Automatic Token Refresh
|
|
222
223
|
|
|
223
|
-
For automatic token refresh on 401
|
|
224
|
+
For automatic token refresh on **401** errors, inject `ITokenRefresher`:
|
|
224
225
|
|
|
225
226
|
```typescript
|
|
226
227
|
import { JwtAbapConnection, SapConfig } from "@mcp-abap-adt/connection";
|
|
@@ -240,7 +241,7 @@ const config: SapConfig = {
|
|
|
240
241
|
jwtToken: await tokenRefresher.getToken(), // Get initial token
|
|
241
242
|
};
|
|
242
243
|
|
|
243
|
-
// Create connection with token refresher - 401
|
|
244
|
+
// Create connection with token refresher - 401 handled automatically
|
|
244
245
|
const connection = new JwtAbapConnection(config, logger, undefined, tokenRefresher);
|
|
245
246
|
await connection.connect();
|
|
246
247
|
|
|
@@ -253,6 +254,18 @@ const response = await connection.makeAdtRequest({
|
|
|
253
254
|
});
|
|
254
255
|
```
|
|
255
256
|
|
|
257
|
+
**A 403 is never treated as an expired token.** It means the server
|
|
258
|
+
authenticated the caller and refused the action anyway, so no credential can
|
|
259
|
+
change the answer. It propagates unchanged — `error.response.status` and the
|
|
260
|
+
server's message, which usually names the authorization object — rather than
|
|
261
|
+
being reported as an expired token.
|
|
262
|
+
|
|
263
|
+
Earlier versions reported both 401 and 403 as
|
|
264
|
+
`JWT token has expired. Please re-authenticate.` and discarded the original
|
|
265
|
+
error. Code matching on that message must branch on `error.response.status`
|
|
266
|
+
instead — which it can now do, since the status is no longer thrown away.
|
|
267
|
+
See [MIGRATION-4.0.md](./docs/MIGRATION-4.0.md).
|
|
268
|
+
|
|
256
269
|
### Stateful Sessions
|
|
257
270
|
|
|
258
271
|
For operations that require session state (e.g., object modifications), you can enable stateful sessions:
|
|
@@ -25,7 +25,7 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
25
25
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
26
26
|
try {
|
|
27
27
|
// Try to get CSRF token (this will also get cookies)
|
|
28
|
-
const token = await this.fetchCsrfToken(discoveryUrl
|
|
28
|
+
const token = await this.fetchCsrfToken(discoveryUrl);
|
|
29
29
|
this.setCsrfToken(token);
|
|
30
30
|
this.logger?.debug('Successfully connected to SAP system', {
|
|
31
31
|
hasCsrfToken: !!this.getCsrfToken(),
|
|
@@ -47,7 +47,7 @@ class CertificateAbapConnection extends AbstractAbapConnection_js_1.AbstractAbap
|
|
|
47
47
|
const baseUrl = await this.getBaseUrl();
|
|
48
48
|
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
49
49
|
try {
|
|
50
|
-
const token = await this.fetchCsrfToken(discoveryUrl
|
|
50
|
+
const token = await this.fetchCsrfToken(discoveryUrl);
|
|
51
51
|
this.setCsrfToken(token);
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type IAdtResponse, type ITokenRefresher } from '@mcp-abap-adt/interfaces';
|
|
2
2
|
import type { SapConfig } from '../config/sapConfig.js';
|
|
3
3
|
import type { ILogger } from '../logger.js';
|
|
4
4
|
import type { AbapRequestOptions } from './AbapConnection.js';
|
|
@@ -7,19 +7,92 @@ import { AbstractAbapConnection } from './AbstractAbapConnection.js';
|
|
|
7
7
|
* JWT Authentication connection for SAP BTP Cloud systems
|
|
8
8
|
*
|
|
9
9
|
* Supports automatic token refresh via ITokenRefresher injection:
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
10
|
+
* - a **401** triggers a token refresh when a tokenRefresher is available;
|
|
11
|
+
* - without one, or when the refresh does not help, the server's own error is
|
|
12
|
+
* thrown unchanged — status and body intact;
|
|
13
|
+
* - a **403** is never a token problem. It propagates as it arrived, because a
|
|
14
|
+
* new token is the same caller and cannot change a permissions answer.
|
|
12
15
|
*/
|
|
13
16
|
export declare class JwtAbapConnection extends AbstractAbapConnection {
|
|
14
17
|
private tokenRefresher?;
|
|
15
18
|
private currentToken;
|
|
19
|
+
/** Bumped by any token refresh, token-only ones included. */
|
|
20
|
+
private tokenGeneration;
|
|
21
|
+
/** Single-flight over the token fetch alone. Touches no session state. */
|
|
22
|
+
private tokenRefreshInFlight?;
|
|
23
|
+
/**
|
|
24
|
+
* The `tokenGeneration` a completed session recovery was built for. Only
|
|
25
|
+
* `performRenewal` moves it, and only after `recoverSession` resolves.
|
|
26
|
+
*
|
|
27
|
+
* Separate from `tokenGeneration` because a token-only refresh — which
|
|
28
|
+
* `fetchCsrfToken` does, from inside an establishment — bumps that counter
|
|
29
|
+
* without rebuilding anything. Reading it as "the session is settled" made
|
|
30
|
+
* the outer handler retry a request whose session was never rebuilt.
|
|
31
|
+
*/
|
|
32
|
+
private recoveredGeneration;
|
|
33
|
+
/**
|
|
34
|
+
* Single-flight over the whole credential renewal, session included.
|
|
35
|
+
*
|
|
36
|
+
* Sharing only the token fetch leaves the expensive half racing: `recover`
|
|
37
|
+
* and `cleanup` never join (`SessionLifecycle.transition`), so two concurrent
|
|
38
|
+
* renewals queue as cleanup A → recover A → cleanup B → recover B, and A's
|
|
39
|
+
* retry meets a session B has just torn down.
|
|
40
|
+
*/
|
|
41
|
+
private renewalInFlight?;
|
|
42
|
+
/**
|
|
43
|
+
* Per connection, deliberately NOT static. `baseline` is compared against
|
|
44
|
+
* `this.tokenGeneration`, which is instance state — a store shared between
|
|
45
|
+
* instances would let one connection's operation hand its baseline to
|
|
46
|
+
* another's, and the comparison would be between unrelated counters.
|
|
47
|
+
*/
|
|
48
|
+
private readonly recoveryScope;
|
|
16
49
|
constructor(config: SapConfig, logger?: ILogger | null, sessionId?: string, tokenRefresher?: ITokenRefresher);
|
|
17
50
|
protected buildAuthorizationHeader(): string;
|
|
18
51
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
52
|
+
* For the public entry point: this call is its own operation, always.
|
|
53
|
+
*
|
|
54
|
+
* A re-entrant `makeAdtRequest` — from a logger or a refresher callback the
|
|
55
|
+
* connection itself invokes while a scope is live — is a new caller-visible
|
|
56
|
+
* operation. Inheriting there would hand it a baseline from somebody else's
|
|
57
|
+
* refresh, which reads as "already refreshed for me" and skips a refresh it
|
|
58
|
+
* needs.
|
|
59
|
+
*/
|
|
60
|
+
private inNewRecoveryScope;
|
|
61
|
+
/** For the inner levels: join the operation in progress, or start one. */
|
|
62
|
+
private inRecoveryScope;
|
|
63
|
+
/**
|
|
64
|
+
* The baseline this operation is reasoning from.
|
|
65
|
+
*
|
|
66
|
+
* The `active` check here and the one in `inRecoveryScope` OVERLAP: each
|
|
67
|
+
* compensates for the other, and the stale-context test only fails when both
|
|
68
|
+
* are removed. Kept as two because they answer different questions — "may I
|
|
69
|
+
* join this scope" and "may I trust this baseline" — and a reader who finds
|
|
70
|
+
* one redundant would be deleting half a guarantee.
|
|
71
|
+
*/
|
|
72
|
+
private currentBaseline;
|
|
73
|
+
/**
|
|
74
|
+
* Fetch a new token, unless somebody already did for this operation.
|
|
75
|
+
*
|
|
76
|
+
* Single-flighted so two concurrent handlers — including two nested
|
|
77
|
+
* `fetchCsrfToken` calls, which is a level `renewalInFlight` cannot reach —
|
|
78
|
+
* share one network call instead of racing and leaving `currentToken` as
|
|
79
|
+
* whichever settled last.
|
|
80
|
+
*
|
|
81
|
+
* @returns true when the caller may retry.
|
|
82
|
+
*/
|
|
83
|
+
private refreshTokenOnce;
|
|
84
|
+
private performTokenRefresh;
|
|
85
|
+
/**
|
|
86
|
+
* Renew the credential and the session it belongs to, once, shared.
|
|
87
|
+
*/
|
|
88
|
+
private renewCredential;
|
|
89
|
+
private performRenewal;
|
|
90
|
+
/**
|
|
91
|
+
* May this operation retry?
|
|
92
|
+
*
|
|
93
|
+
* The order of the checks is the design, not style.
|
|
21
94
|
*/
|
|
22
|
-
private
|
|
95
|
+
private ensureRecovered;
|
|
23
96
|
/**
|
|
24
97
|
* Establishes the session for this auth type. Called by
|
|
25
98
|
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
@@ -29,10 +102,14 @@ export declare class JwtAbapConnection extends AbstractAbapConnection {
|
|
|
29
102
|
* Override makeAdtRequest to handle JWT auth errors with automatic token refresh
|
|
30
103
|
*/
|
|
31
104
|
makeAdtRequest<T = any, D = any>(options: AbapRequestOptions): Promise<IAdtResponse<T, D>>;
|
|
105
|
+
private attemptRequest;
|
|
32
106
|
/**
|
|
33
107
|
* Override fetchCsrfToken to handle JWT auth errors with automatic token refresh
|
|
34
108
|
*/
|
|
35
|
-
protected fetchCsrfToken(url: string, retryCount?: number, retryDelay?: number
|
|
109
|
+
protected fetchCsrfToken(url: string, retryCount?: number, retryDelay?: number,
|
|
110
|
+
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
111
|
+
generation?: number): Promise<string>;
|
|
112
|
+
private attemptCsrfToken;
|
|
36
113
|
private static validateConfig;
|
|
37
114
|
}
|
|
38
115
|
//# sourceMappingURL=JwtAbapConnection.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,eAAe,EACrB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAuBrE;;;;;;;;;GASG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;IAC3D,OAAO,CAAC,cAAc,CAAC,CAAkB;IACzC,OAAO,CAAC,YAAY,CAAS;IAE7B,6DAA6D;IAC7D,OAAO,CAAC,eAAe,CAAK;IAE5B,0EAA0E;IAC1E,OAAO,CAAC,oBAAoB,CAAC,CAAmB;IAEhD;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB,CAAK;IAEhC;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe,CAAC,CAAmB;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA2C;gBAGvE,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;;;;;;;;OAQG;IACH,OAAO,CAAC,kBAAkB;IAc1B,0EAA0E;IAC1E,OAAO,CAAC,eAAe;IASvB;;;;;;;;OAQG;IACH,OAAO,CAAC,eAAe;IAQvB;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;YAsBV,mBAAmB;IAoBjC;;OAEG;IACH,OAAO,CAAC,eAAe;YAiBT,cAAc;IAwB5B;;;;OAIG;YACW,eAAe;IAyB7B;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BjD;;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;YAKhB,cAAc;IA8D5B;;OAEG;cACa,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,UAAU,GAAE,MAAgC,EAC5C,UAAU,GAAE,MAAgC;IAC5C,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC;YASJ,gBAAgB;IAkD9B,OAAO,CAAC,MAAM,CAAC,cAAc;CAa9B"}
|
|
@@ -1,18 +1,66 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.JwtAbapConnection = void 0;
|
|
4
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
5
|
+
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
4
6
|
const axios_1 = require("axios");
|
|
7
|
+
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
5
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
|
+
}
|
|
6
21
|
/**
|
|
7
22
|
* JWT Authentication connection for SAP BTP Cloud systems
|
|
8
23
|
*
|
|
9
24
|
* Supports automatic token refresh via ITokenRefresher injection:
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
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.
|
|
12
30
|
*/
|
|
13
31
|
class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnection {
|
|
14
32
|
tokenRefresher;
|
|
15
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();
|
|
16
64
|
constructor(config, logger, sessionId, tokenRefresher) {
|
|
17
65
|
JwtAbapConnection.validateConfig(config);
|
|
18
66
|
super(config, logger || null, sessionId);
|
|
@@ -31,18 +79,90 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
31
79
|
return `Bearer ${this.currentToken}`;
|
|
32
80
|
}
|
|
33
81
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
139
|
*/
|
|
37
|
-
|
|
140
|
+
refreshTokenOnce(baseline) {
|
|
141
|
+
if (this.tokenGeneration > baseline)
|
|
142
|
+
return Promise.resolve(true);
|
|
143
|
+
if (this.tokenRefreshInFlight)
|
|
144
|
+
return this.tokenRefreshInFlight;
|
|
38
145
|
if (!this.tokenRefresher) {
|
|
39
146
|
this.logger?.debug(`[DEBUG] JwtAbapConnection - No tokenRefresher available, cannot refresh token`);
|
|
40
|
-
return false;
|
|
147
|
+
return Promise.resolve(false);
|
|
41
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() {
|
|
42
161
|
try {
|
|
43
162
|
this.logger?.debug(`[DEBUG] JwtAbapConnection - Refreshing token via tokenRefresher...`);
|
|
44
|
-
|
|
45
|
-
this.currentToken =
|
|
163
|
+
// biome-ignore lint/style/noNonNullAssertion: refreshTokenOnce checks it
|
|
164
|
+
this.currentToken = await this.tokenRefresher.refreshToken();
|
|
165
|
+
this.tokenGeneration += 1;
|
|
46
166
|
this.logger?.debug(`[DEBUG] JwtAbapConnection - Token refreshed successfully`);
|
|
47
167
|
return true;
|
|
48
168
|
}
|
|
@@ -51,6 +171,68 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
51
171
|
return false;
|
|
52
172
|
}
|
|
53
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
|
+
}
|
|
54
236
|
/**
|
|
55
237
|
* Establishes the session for this auth type. Called by
|
|
56
238
|
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
@@ -61,7 +243,7 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
61
243
|
this.logger?.debug(`[DEBUG] JwtAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
62
244
|
try {
|
|
63
245
|
// Try to get CSRF token (this will also get cookies)
|
|
64
|
-
const token = await this.fetchCsrfToken(discoveryUrl
|
|
246
|
+
const token = await this.fetchCsrfToken(discoveryUrl);
|
|
65
247
|
this.setCsrfToken(token);
|
|
66
248
|
this.logger?.debug('Successfully connected to SAP system', {
|
|
67
249
|
hasCsrfToken: !!this.getCsrfToken(),
|
|
@@ -70,31 +252,9 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
70
252
|
});
|
|
71
253
|
}
|
|
72
254
|
catch (error) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(error.response?.status === 401 || error.response?.status === 403)) {
|
|
76
|
-
// Check if this is really an auth error, not a permissions error
|
|
77
|
-
const responseData = error.response?.data;
|
|
78
|
-
const responseText = typeof responseData === 'string'
|
|
79
|
-
? responseData
|
|
80
|
-
: JSON.stringify(responseData || '');
|
|
81
|
-
// Don't retry on "No Access" errors
|
|
82
|
-
if (responseText.includes('ExceptionResourceNoAccess') ||
|
|
83
|
-
responseText.includes('No authorization') ||
|
|
84
|
-
responseText.includes('Missing authorization')) {
|
|
85
|
-
throw error;
|
|
86
|
-
}
|
|
87
|
-
// Try to refresh token if tokenRefresher is available
|
|
88
|
-
if (await this.tryRefreshToken()) {
|
|
89
|
-
// Retry the ESTABLISHMENT, not connect(): connect() runs this as a
|
|
90
|
-
// joinable transition, so a nested call joins the transition already
|
|
91
|
-
// in flight — this one — and waits for itself forever.
|
|
92
|
-
this.logger?.debug(`[DEBUG] JwtAbapConnection - Retrying establishment after token refresh...`);
|
|
93
|
-
return this.establishSession();
|
|
94
|
-
}
|
|
95
|
-
throw new Error('JWT token has expired. Please re-authenticate.');
|
|
255
|
+
if (isTokenExpiryCandidate(error)) {
|
|
256
|
+
this.logger?.error('[ERROR] JwtAbapConnection.establishSession - 401 while establishing; fetchCsrfToken has already refreshed and retried for this');
|
|
96
257
|
}
|
|
97
|
-
// Re-throw other errors
|
|
98
258
|
throw error;
|
|
99
259
|
}
|
|
100
260
|
}
|
|
@@ -102,6 +262,10 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
102
262
|
* Override makeAdtRequest to handle JWT auth errors with automatic token refresh
|
|
103
263
|
*/
|
|
104
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) {
|
|
105
269
|
// Captured before the attempt: a recovery asks "has the caller asked to
|
|
106
270
|
// stop since this request began", not since some later bookkeeping step.
|
|
107
271
|
const baselineEpoch = this.teardownEpoch;
|
|
@@ -113,34 +277,32 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
113
277
|
}
|
|
114
278
|
catch (error) {
|
|
115
279
|
this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await this.recoverSession(baselineEpoch);
|
|
141
|
-
return super.makeAdtRequest(options);
|
|
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
|
+
}
|
|
142
304
|
}
|
|
143
|
-
|
|
305
|
+
this.logger?.error('[ERROR] JwtAbapConnection.makeAdtRequest - 401 persists and the token could not be refreshed; the credential may need re-authentication');
|
|
144
306
|
}
|
|
145
307
|
throw error;
|
|
146
308
|
}
|
|
@@ -148,33 +310,37 @@ class JwtAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnecti
|
|
|
148
310
|
/**
|
|
149
311
|
* Override fetchCsrfToken to handle JWT auth errors with automatic token refresh
|
|
150
312
|
*/
|
|
151
|
-
async fetchCsrfToken(url, retryCount =
|
|
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) {
|
|
152
322
|
try {
|
|
153
323
|
// Try to fetch CSRF token using parent implementation
|
|
154
|
-
return await super.fetchCsrfToken(url, retryCount, retryDelay);
|
|
324
|
+
return await super.fetchCsrfToken(url, retryCount, retryDelay, generation);
|
|
155
325
|
}
|
|
156
326
|
catch (error) {
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const responseData = error.response?.data;
|
|
162
|
-
const responseText = typeof responseData === 'string'
|
|
163
|
-
? responseData
|
|
164
|
-
: JSON.stringify(responseData || '');
|
|
165
|
-
// Don't retry on "No Access" errors
|
|
166
|
-
if (responseText.includes('ExceptionResourceNoAccess') ||
|
|
167
|
-
responseText.includes('No authorization') ||
|
|
168
|
-
responseText.includes('Missing authorization')) {
|
|
169
|
-
throw error;
|
|
170
|
-
}
|
|
171
|
-
// Try to refresh token if tokenRefresher is available
|
|
172
|
-
if (await this.tryRefreshToken()) {
|
|
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())) {
|
|
173
331
|
// Retry CSRF token fetch with new token
|
|
174
332
|
this.logger?.debug(`[DEBUG] JwtAbapConnection.fetchCsrfToken - Retrying after token refresh...`);
|
|
175
|
-
|
|
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
|
+
}
|
|
176
342
|
}
|
|
177
|
-
|
|
343
|
+
this.logger?.error('[ERROR] JwtAbapConnection.fetchCsrfToken - 401 persists and the token could not be refreshed; the credential may need re-authentication');
|
|
178
344
|
}
|
|
179
345
|
// Re-throw other errors
|
|
180
346
|
throw error;
|
|
@@ -29,7 +29,7 @@ class SamlAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
29
29
|
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
30
30
|
this.logger?.debug(`[DEBUG] SamlAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
31
31
|
try {
|
|
32
|
-
const token = await this.fetchCsrfToken(discoveryUrl
|
|
32
|
+
const token = await this.fetchCsrfToken(discoveryUrl);
|
|
33
33
|
this.setCsrfToken(token);
|
|
34
34
|
this.logger?.debug('Successfully connected to SAP system', {
|
|
35
35
|
hasCsrfToken: !!this.getCsrfToken(),
|
package/docs/INDEX.md
CHANGED
|
@@ -15,6 +15,7 @@ mcp-abap-connection/
|
|
|
15
15
|
│ ├── INSTALLATION.md # Setup and installation guide
|
|
16
16
|
│ ├── USAGE.md # API documentation and examples
|
|
17
17
|
│ ├── MIGRATION-2.0.md # Moving to the explicit session lifecycle
|
|
18
|
+
│ ├── MIGRATION-4.0.md # JWT error classification: 401 refreshes, 403 propagates
|
|
18
19
|
│ ├── SCOPE.md # What this package does and does not own
|
|
19
20
|
│ ├── STATEFUL_SESSION_GUIDE.md # Stateful requests and lock windows
|
|
20
21
|
│ └── JWT_AUTH_TOOLS.md # CLI tool for authentication
|
|
@@ -41,6 +42,10 @@ mcp-abap-connection/
|
|
|
41
42
|
### Core Features
|
|
42
43
|
- 🔑 [JWT Auth Tools](./JWT_AUTH_TOOLS.md) - CLI tool for browser-based authentication
|
|
43
44
|
|
|
45
|
+
### Upgrading
|
|
46
|
+
- 🧱 [Migrating to 4.0.0](./MIGRATION-4.0.md) - JWT error classification: a 401 refreshes, a 403 propagates with the server's message
|
|
47
|
+
- 🧱 [Migrating to 2.0.0](./MIGRATION-2.0.md) - The explicit session lifecycle: `connect()` is required
|
|
48
|
+
|
|
44
49
|
### Version Information
|
|
45
50
|
- 📋 [CHANGELOG](../CHANGELOG.md) - Complete version history, including what the latest release changed
|
|
46
51
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Migrating to 4.0.0: the server's error reaches you
|
|
2
|
+
|
|
3
|
+
A JWT connection used to answer some failures with an error of its own making:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Error: JWT token has expired. Please re-authenticate.
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It said that for a **401** and for a **403** alike, and it threw the original `AxiosError` away
|
|
10
|
+
along with the status and the body. Now the server's error reaches the caller unchanged, and only
|
|
11
|
+
a 401 is treated as a credential problem.
|
|
12
|
+
|
|
13
|
+
## What breaks, and what to do
|
|
14
|
+
|
|
15
|
+
### 1. That message is gone
|
|
16
|
+
|
|
17
|
+
If anything matches on it, it will stop matching.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// before
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.message.includes('JWT token has expired')) {
|
|
23
|
+
await reauthenticate();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// after
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.response?.status === 401) {
|
|
30
|
+
await reauthenticate();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`error.response.status` and `error.response.data` are available now — they were not before,
|
|
36
|
+
because the replacement error carried neither.
|
|
37
|
+
|
|
38
|
+
**Why it changed.** The message was wrong about half the cases it was used for. A 403 is not an
|
|
39
|
+
expired token, and telling a caller to re-authenticate for one sends them to fix the single thing
|
|
40
|
+
that is not broken.
|
|
41
|
+
|
|
42
|
+
### 2. A 403 no longer triggers a token refresh
|
|
43
|
+
|
|
44
|
+
It propagates as it arrived. If your code relied on a refresh happening after a 403 — for
|
|
45
|
+
instance to recover from an authorization gap that a *different* principal would not have — that
|
|
46
|
+
no longer happens, and it never worked for the reason you may have assumed: a refreshed token
|
|
47
|
+
belongs to the same caller and cannot change a permissions answer.
|
|
48
|
+
|
|
49
|
+
What a 403 usually carries is worth reading:
|
|
50
|
+
|
|
51
|
+
```xml
|
|
52
|
+
<exc:exception>
|
|
53
|
+
<type id="ExceptionResourceNoAuthorization"/>
|
|
54
|
+
<message lang="EN">You are not authorized to make changes (authorization object S_DEVELOP)</message>
|
|
55
|
+
</exc:exception>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The authorization object is named. That is actionable; "please re-authenticate" was not.
|
|
59
|
+
|
|
60
|
+
### 3. A 401 still refreshes, and now says so when it does not help
|
|
61
|
+
|
|
62
|
+
The happy path is unchanged: a 401 with an injected `ITokenRefresher` fetches a new token,
|
|
63
|
+
re-establishes the session and retries. What changed is the unhappy one — when the retry comes
|
|
64
|
+
back 401 as well, the original error is rethrown and an `ERROR`-level log line explains that the
|
|
65
|
+
credential may genuinely need re-authentication.
|
|
66
|
+
|
|
67
|
+
## What did not change
|
|
68
|
+
|
|
69
|
+
- `ITokenRefresher` and how it is injected;
|
|
70
|
+
- that a credential renewal replaces the SAP session — with a lock window open, a request still
|
|
71
|
+
fails with `ADT_SESSION_REPLACED` rather than continuing on a session your lock is not in (see
|
|
72
|
+
[STATEFUL_SESSION_GUIDE.md](./STATEFUL_SESSION_GUIDE.md));
|
|
73
|
+
- every other authentication type. `BaseAbapConnection`, `SamlAbapConnection`,
|
|
74
|
+
`CertificateAbapConnection` and `KerberosAbapConnection` never refreshed tokens and never
|
|
75
|
+
synthesised an error in place of the server's.
|
|
76
|
+
|
|
77
|
+
## Under the hood, if you are debugging
|
|
78
|
+
|
|
79
|
+
Concurrent requests that hit the same expired token now share **one** renewal — one token fetch
|
|
80
|
+
and one session re-establishment between them — rather than each running its own teardown and
|
|
81
|
+
recovery in sequence. If you have log-line counts or timing expectations built around the old
|
|
82
|
+
behaviour, they will change; the observable contract does not.
|
|
83
|
+
|
|
84
|
+
## Where this came from
|
|
85
|
+
|
|
86
|
+
Issue [#30](https://github.com/fr0ster/mcp-abap-connection/issues/30), found while probing ATC on
|
|
87
|
+
a cloud trial: creating a classic program was reported as an expired token on three consecutive
|
|
88
|
+
runs, while the same session was answering other requests with 200. It took bypassing the
|
|
89
|
+
connector on the same credential to see the 403 and the `S_DEVELOP` message underneath.
|
|
90
|
+
|
|
91
|
+
One question is deliberately left open and tracked in
|
|
92
|
+
[#32](https://github.com/fr0ster/mcp-abap-connection/issues/32): whether some BTP setup answers an
|
|
93
|
+
*invalid* token with 403 rather than 401. Nothing captured shows that it does, and preserving the
|
|
94
|
+
original error is what makes the assumption safe to be wrong about — a caller would then see the
|
|
95
|
+
server's own 403 instead of a misleading message.
|
|
@@ -82,6 +82,12 @@ Every ADT request issued through `makeAdtRequest` automatically:
|
|
|
82
82
|
|
|
83
83
|
This logic is transparent to callers (Builders, handlers, CLI scripts).
|
|
84
84
|
|
|
85
|
+
On a JWT connection one case is **not** transparent, and cannot be: a 401 that leads to a token
|
|
86
|
+
refresh also replaces the SAP session, because the renewed credential cannot keep the old one.
|
|
87
|
+
Inside a lock window that surfaces as `ADT_SESSION_REPLACED` rather than a request quietly
|
|
88
|
+
continuing on a session your lock is not in. A 403 never does this — it is an authorization
|
|
89
|
+
answer, not a credential one, and nothing is torn down for it.
|
|
90
|
+
|
|
85
91
|
---
|
|
86
92
|
|
|
87
93
|
## Interaction With ADT Clients
|
package/docs/USAGE.md
CHANGED
|
@@ -676,12 +676,33 @@ For SAP BTP cloud systems. Token refresh is handled by `@mcp-abap-adt/auth-broke
|
|
|
676
676
|
|
|
677
677
|
```typescript
|
|
678
678
|
class JwtAbapConnection extends AbstractAbapConnection {
|
|
679
|
-
constructor(
|
|
679
|
+
constructor(
|
|
680
|
+
config: SapConfig,
|
|
681
|
+
logger?: ILogger | null,
|
|
682
|
+
sessionId?: string,
|
|
683
|
+
tokenRefresher?: ITokenRefresher,
|
|
684
|
+
);
|
|
680
685
|
// Note: refreshToken() and canRefreshToken() methods removed in 0.2.0
|
|
681
|
-
//
|
|
686
|
+
// Token acquisition itself belongs to @mcp-abap-adt/auth-broker
|
|
682
687
|
}
|
|
683
688
|
```
|
|
684
689
|
|
|
690
|
+
**How failures are classified** (4.0.0 — see
|
|
691
|
+
[MIGRATION-4.0.md](./MIGRATION-4.0.md)):
|
|
692
|
+
|
|
693
|
+
| answer | what happens |
|
|
694
|
+
|---|---|
|
|
695
|
+
| **401** | with an injected `ITokenRefresher`, the token is refreshed, the SAP session re-established, and the request retried once. Without one, or when the retry is refused too, the server's error is rethrown |
|
|
696
|
+
| **403** | propagates untouched. The server authenticated the caller and refused the action anyway, so a new token is the same caller — usually the body names the authorization object |
|
|
697
|
+
| anything else | untouched |
|
|
698
|
+
|
|
699
|
+
The connection never replaces the server's error with one of its own: `error.response.status` and
|
|
700
|
+
`error.response.data` are always what SAP sent. Before 4.0.0 both 401 and 403 were reported as
|
|
701
|
+
`JWT token has expired. Please re-authenticate.` with the original error discarded.
|
|
702
|
+
|
|
703
|
+
Concurrent requests that meet the same expired token share **one** renewal — a single token fetch
|
|
704
|
+
and a single session re-establishment between them, not one each.
|
|
705
|
+
|
|
685
706
|
### `createAbapConnection()` Factory
|
|
686
707
|
|
|
687
708
|
Recommended way to create connections:
|
package/examples/README.md
CHANGED
|
@@ -43,8 +43,9 @@ node examples/jwt-with-token-refresh.js
|
|
|
43
43
|
|
|
44
44
|
**What it demonstrates:**
|
|
45
45
|
- Creating connection with token refresher injection
|
|
46
|
-
- Automatic token refresh on 401
|
|
46
|
+
- Automatic token refresh on 401 errors
|
|
47
47
|
- Retry logic with refreshed token
|
|
48
|
+
- A 403 propagating untouched, with its status and body intact
|
|
48
49
|
|
|
49
50
|
### saml-connection.js
|
|
50
51
|
|
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
* This example demonstrates how to create a JwtAbapConnection with
|
|
5
5
|
* automatic token refresh using ITokenRefresher from auth-broker.
|
|
6
6
|
*
|
|
7
|
-
* When 401
|
|
7
|
+
* When a **401** occurs, the connection automatically:
|
|
8
8
|
* 1. Calls tokenRefresher.refreshToken() to get a new token
|
|
9
9
|
* 2. Updates internal token state
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. Re-establishes the SAP session, which the new credential cannot inherit
|
|
11
|
+
* 4. Retries the failed request
|
|
12
|
+
*
|
|
13
|
+
* A **403** is left alone. It means the server authenticated you and refused
|
|
14
|
+
* the action anyway — an authorization gap, not an expired credential — so it
|
|
15
|
+
* propagates with its status and the server's message, and a new token would
|
|
16
|
+
* change nothing.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
const { JwtAbapConnection } = require('@mcp-abap-adt/connection');
|
|
@@ -68,7 +74,9 @@ async function main() {
|
|
|
68
74
|
try {
|
|
69
75
|
await connection.connect();
|
|
70
76
|
|
|
71
|
-
// This request will automatically refresh token if 401
|
|
77
|
+
// This request will automatically refresh the token if a 401 occurs. A
|
|
78
|
+
// 403 arrives as-is — read err.response.status and err.response.data to
|
|
79
|
+
// see which authorization object the server named. Note
|
|
72
80
|
// that a refresh replaces the SAP session: with a lock window open the
|
|
73
81
|
// request would fail with ADT_SESSION_REPLACED rather than continue on a
|
|
74
82
|
// session your lock is not in.
|