@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 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/v3.0.0...HEAD
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/403 errors, inject `ITokenRefresher`:
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/403 handled automatically
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, 3, 1000);
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, 3, 1000);
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 { IAdtResponse, ITokenRefresher } from '@mcp-abap-adt/interfaces';
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
- * - If tokenRefresher is provided, 401/403 errors trigger automatic token refresh
11
- * - If tokenRefresher is not provided, 401/403 errors throw an error (legacy behavior)
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
- * Refresh the JWT token using the injected tokenRefresher
20
- * @returns true if token was refreshed, false if no refresher available
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 tryRefreshToken;
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): Promise<string>;
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":"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"}
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
- * - If tokenRefresher is provided, 401/403 errors trigger automatic token refresh
11
- * - If tokenRefresher is not provided, 401/403 errors throw an error (legacy behavior)
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
- * Refresh the JWT token using the injected tokenRefresher
35
- * @returns true if token was refreshed, false if no refresher available
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
- async tryRefreshToken() {
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
- const newToken = await this.tokenRefresher.refreshToken();
45
- this.currentToken = newToken;
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, 3, 1000);
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
- // Handle JWT auth errors (401/403) during connect
74
- if (error instanceof axios_1.AxiosError &&
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
- // Handle JWT auth errors (401/403)
117
- if (error instanceof axios_1.AxiosError &&
118
- (error.response?.status === 401 || error.response?.status === 403)) {
119
- this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Got ${error.response.status}, attempting token refresh...`);
120
- // Check if this is really an auth error, not a permissions error
121
- const responseData = error.response?.data;
122
- const responseText = typeof responseData === 'string'
123
- ? responseData
124
- : JSON.stringify(responseData || '');
125
- // Don't retry on "No Access" errors - these are permission issues, not auth issues
126
- if (responseText.includes('ExceptionResourceNoAccess') ||
127
- responseText.includes('No authorization') ||
128
- responseText.includes('Missing authorization')) {
129
- throw error;
130
- }
131
- // Try to refresh token if tokenRefresher is available
132
- if (await this.tryRefreshToken()) {
133
- this.logger?.debug(`[DEBUG] JwtAbapConnection.makeAdtRequest - Recovering session after token refresh...`);
134
- // The renewed credential cannot keep the old ABAP session, so this is
135
- // a session-lost teardown — internal, or it would cancel the very
136
- // recovery it is setting up. reset() would be the caller-origin one.
137
- this.discardSession();
138
- // Re-establish before retrying: the retry goes through admission, and
139
- // a discarded session admits nothing.
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
- throw new Error('JWT token has expired. Please re-authenticate.');
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 = 3, retryDelay = 1000) {
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
- // Handle JWT auth errors (401/403) during CSRF token fetch
158
- if (error instanceof axios_1.AxiosError &&
159
- (error.response?.status === 401 || error.response?.status === 403)) {
160
- // Check if this is really an auth error, not a permissions error
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
- return super.fetchCsrfToken(url, retryCount, retryDelay);
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
- throw new Error('JWT token has expired. Please re-authenticate.');
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, 3, 1000);
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(config: SapConfig, logger?: ILogger | null);
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
- // Use @mcp-abap-adt/auth-broker for token refresh functionality
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:
@@ -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/403 errors
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/403 errors occur, the connection automatically:
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. Retries the failed request with the new token
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/403 occurs. Note
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcp-abap-adt/connection",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "ABAP connection layer for MCP ABAP ADT server",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",