@mcp-abap-adt/auth-broker 2.2.0 → 3.0.1

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.
@@ -1,653 +1,295 @@
1
1
  "use strict";
2
2
  /**
3
- * Main AuthBroker class for managing JWT tokens based on destinations
3
+ * AuthBroker: tokens for a destination, from a provider, kept in a session store.
4
+ *
5
+ * The broker orchestrates and nothing more. It resolves what the stores know
6
+ * about a destination, hands it to the provider, asks the provider for a token
7
+ * and writes the answer back. Whether a token is still valid, whether to use the
8
+ * refresh token or log in, and how a login is conducted (browser, headless,
9
+ * pasted code) are the provider's decisions — made by its strategy — and the
10
+ * broker does not repeat or override any of them.
4
11
  */
5
12
  Object.defineProperty(exports, "__esModule", { value: true });
6
13
  exports.AuthBroker = void 0;
7
14
  const interfaces_auth_1 = require("@mcp-abap-adt/interfaces-auth");
8
- const formatting_1 = require("./utils/formatting");
9
- /**
10
- * No-op logger implementation for default fallback when logger is not provided
11
- */
12
15
  const noOpLogger = {
13
16
  info: () => { },
14
17
  error: () => { },
15
18
  warn: () => { },
16
19
  debug: () => { },
17
20
  };
18
- /**
19
- * Helper function to check if error has a code property
20
- */
21
- // biome-ignore lint/suspicious/noExplicitAny: Helper function needs to accept any error type
22
- function hasErrorCode(error) {
23
- return (error !== null &&
24
- typeof error === 'object' &&
25
- 'code' in error &&
26
- typeof error.code === 'string');
27
- }
28
- /**
29
- * Helper function to get error message safely
30
- */
31
- // biome-ignore lint/suspicious/noExplicitAny: Helper function needs to accept any error type
32
- function getErrorMessage(error) {
33
- return error instanceof Error ? error.message : String(error);
34
- }
35
- /**
36
- * Whether an error represents a failed *interactive* browser login (the user
37
- * did not complete the OAuth flow / it timed out), as opposed to a transient or
38
- * configuration error. Such failures must not be retried via a second
39
- * provider.getTokens() — that would start a duplicate browser login on the same
40
- * redirect port and mask the real cause with a "Port in use" error.
41
- */
42
- function isInteractiveAuthFailure(error) {
43
- if (hasErrorCode(error) && error.code === 'BROWSER_AUTH_ERROR') {
44
- return true;
21
+ function errorCode(error) {
22
+ if (error !== null && typeof error === 'object' && 'code' in error) {
23
+ const code = error.code;
24
+ return typeof code === 'string' ? code : undefined;
45
25
  }
46
- const message = getErrorMessage(error);
47
- return /authentication timeout|browser authentication|already in use/i.test(message);
26
+ return undefined;
48
27
  }
49
28
  /**
50
- * AuthBroker manages JWT authentication tokens for destinations
29
+ * AuthBroker manages authentication tokens for destinations
51
30
  */
52
31
  class AuthBroker {
53
- browser;
54
32
  logger;
55
33
  serviceKeyStore;
56
34
  sessionStore;
57
- tokenProvider;
58
- allowBrowserAuth;
35
+ provider;
36
+ providers = new Map();
59
37
  /**
60
- * Create a new AuthBroker instance
61
- * @param config Configuration object with stores and token provider
62
- * - sessionStore: Store for session data (required)
63
- * - serviceKeyStore: Store for service keys (optional)
64
- * - tokenProvider: Token provider implementing ITokenProvider interface (required) - handles browser-based authorization
65
- * @param browser Optional browser name for authentication (chrome, edge, firefox, system, headless, none).
66
- * Default: 'system' (system default browser).
67
- * Use 'headless' for SSH/remote sessions - logs URL and waits for manual callback.
68
- * Use 'none' for automated tests - logs URL and rejects immediately.
69
- * @param logger Optional logger instance implementing ILogger interface. If not provided, uses no-op logger.
38
+ * @param config Stores and the provider (instance or factory)
39
+ * @param logger Optional logger. Nothing the broker logs contains a token.
70
40
  */
71
- constructor(config, browser, logger) {
72
- // Validate that config is provided
41
+ constructor(config, logger) {
73
42
  if (!config) {
74
43
  throw new Error('AuthBroker: config parameter is required');
75
44
  }
76
- // Validate required sessionStore
77
- if (!config.sessionStore) {
45
+ const { sessionStore, serviceKeyStore, provider } = config;
46
+ if (!sessionStore) {
78
47
  throw new Error('AuthBroker: sessionStore is required');
79
48
  }
80
- // Validate required tokenProvider
81
- if (!config.tokenProvider) {
82
- throw new Error('AuthBroker: tokenProvider is required');
83
- }
84
- // Validate that stores and provider are correctly instantiated (have required methods)
85
- const sessionStore = config.sessionStore;
86
- const tokenProvider = config.tokenProvider;
87
- const serviceKeyStore = config.serviceKeyStore;
88
- // Check sessionStore methods
89
- if (typeof sessionStore.getAuthorizationConfig !== 'function') {
90
- throw new Error('AuthBroker: sessionStore.getAuthorizationConfig must be a function');
91
- }
92
- if (typeof sessionStore.getConnectionConfig !== 'function') {
93
- throw new Error('AuthBroker: sessionStore.getConnectionConfig must be a function');
94
- }
95
- if (typeof sessionStore.setAuthorizationConfig !== 'function') {
96
- throw new Error('AuthBroker: sessionStore.setAuthorizationConfig must be a function');
97
- }
98
- if (typeof sessionStore.setConnectionConfig !== 'function') {
99
- throw new Error('AuthBroker: sessionStore.setConnectionConfig must be a function');
100
- }
101
- // Check tokenProvider methods (required)
102
- if (typeof tokenProvider.getTokens !== 'function') {
103
- throw new Error('AuthBroker: tokenProvider.getTokens must be a function');
104
- }
105
- // validateToken is optional, so we don't check it
106
- // Check serviceKeyStore methods (if provided)
107
- if (serviceKeyStore) {
108
- if (typeof serviceKeyStore.getServiceKey !== 'function') {
109
- throw new Error('AuthBroker: serviceKeyStore.getServiceKey must be a function');
110
- }
111
- if (typeof serviceKeyStore.getAuthorizationConfig !== 'function') {
112
- throw new Error('AuthBroker: serviceKeyStore.getAuthorizationConfig must be a function');
113
- }
114
- if (typeof serviceKeyStore.getConnectionConfig !== 'function') {
115
- throw new Error('AuthBroker: serviceKeyStore.getConnectionConfig must be a function');
49
+ if (!provider) {
50
+ throw new Error('AuthBroker: provider is required');
51
+ }
52
+ for (const method of [
53
+ 'getAuthorizationConfig',
54
+ 'getConnectionConfig',
55
+ 'setAuthorizationConfig',
56
+ 'setConnectionConfig',
57
+ 'loadSession',
58
+ 'saveSession',
59
+ ]) {
60
+ if (typeof sessionStore[method] !== 'function') {
61
+ throw new Error(`AuthBroker: sessionStore.${method} must be a function`);
116
62
  }
117
63
  }
118
- this.serviceKeyStore = serviceKeyStore;
119
- this.sessionStore = sessionStore;
120
- this.tokenProvider = tokenProvider;
121
- this.browser = browser || 'system';
122
- this.logger = logger || noOpLogger;
123
- this.allowBrowserAuth = config.allowBrowserAuth ?? true;
124
- // Log successful initialization
125
- const hasServiceKeyStore = !!this.serviceKeyStore;
126
- this.logger?.info('[AuthBroker] Broker initialized', {
127
- hasServiceKeyStore,
128
- hasSessionStore: true,
129
- hasTokenProvider: true,
130
- browser: this.browser,
131
- allowBrowserAuth: this.allowBrowserAuth,
132
- });
133
- }
134
- /**
135
- * Load session data (connection and authorization configs)
136
- */
137
- async loadSessionData(destination) {
138
- let connConfig = null;
139
- let authConfig = null;
140
- try {
141
- connConfig = await this.sessionStore.getConnectionConfig(destination);
142
- }
143
- catch (error) {
144
- if (hasErrorCode(error)) {
145
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
146
- this.logger?.debug(`Session file not found for ${destination}: ${error.filePath || 'unknown path'}`);
147
- }
148
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
149
- this.logger?.warn(`Failed to parse session file for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
150
- }
151
- else {
152
- this.logger?.warn(`Failed to get connection config from session store for ${destination}: ${getErrorMessage(error)}`);
153
- }
64
+ if (typeof provider !== 'function') {
65
+ if (typeof provider.getTokens !== 'function') {
66
+ throw new Error('AuthBroker: provider.getTokens must be a function');
154
67
  }
155
- else {
156
- this.logger?.warn(`Failed to get connection config from session store for ${destination}: ${getErrorMessage(error)}`);
68
+ if (typeof provider.refreshTokens !== 'function') {
69
+ throw new Error('AuthBroker: provider.refreshTokens must be a function');
157
70
  }
158
71
  }
159
- try {
160
- authConfig = await this.sessionStore.getAuthorizationConfig(destination);
161
- }
162
- catch (error) {
163
- if (hasErrorCode(error)) {
164
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
165
- this.logger?.debug(`Session file not found for ${destination}: ${error.filePath || 'unknown path'}`);
166
- }
167
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
168
- this.logger?.warn(`Failed to parse session file for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
169
- }
170
- else {
171
- this.logger?.warn(`Failed to get authorization config from session store for ${destination}: ${getErrorMessage(error)}`);
72
+ if (serviceKeyStore) {
73
+ for (const method of [
74
+ 'getServiceKey',
75
+ 'getAuthorizationConfig',
76
+ 'getConnectionConfig',
77
+ ]) {
78
+ if (typeof serviceKeyStore[method] !== 'function') {
79
+ throw new Error(`AuthBroker: serviceKeyStore.${method} must be a function`);
172
80
  }
173
81
  }
174
- else {
175
- this.logger?.warn(`Failed to get authorization config from session store for ${destination}: ${getErrorMessage(error)}`);
176
- }
177
82
  }
178
- return { connConfig, authConfig };
83
+ this.sessionStore = sessionStore;
84
+ this.serviceKeyStore = serviceKeyStore;
85
+ this.provider = provider;
86
+ this.logger = logger ?? noOpLogger;
87
+ this.logger.debug('[AuthBroker] Broker initialized', {
88
+ hasServiceKeyStore: !!serviceKeyStore,
89
+ providerForm: typeof provider === 'function' ? 'factory' : 'instance',
90
+ });
179
91
  }
180
92
  /**
181
- * Get serviceUrl from session or service key store
93
+ * A token for the destination: the provider's current one, which it refreshes
94
+ * or obtains by login when it judges the cached one unusable.
95
+ *
96
+ * The result is written to the session store. Errors from the provider
97
+ * (its typed errors included) propagate unchanged.
182
98
  */
183
- async getServiceUrl(destination, connConfig) {
184
- let serviceUrl = connConfig?.serviceUrl;
185
- if (!serviceUrl && this.serviceKeyStore) {
186
- try {
187
- const serviceKeyConnConfig = await this.serviceKeyStore.getConnectionConfig(destination);
188
- serviceUrl = serviceKeyConnConfig?.serviceUrl;
189
- if (serviceUrl) {
190
- this.logger?.debug(`serviceUrl not in session for ${destination}, found in serviceKeyStore`);
191
- }
192
- }
193
- catch (error) {
194
- if (hasErrorCode(error)) {
195
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
196
- this.logger?.debug(`Service key file not found for ${destination}: ${error.filePath || 'unknown path'}`);
197
- }
198
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
199
- this.logger?.warn(`Failed to parse service key for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
200
- }
201
- else {
202
- this.logger?.warn(`Failed to get serviceUrl from service key store for ${destination}: ${getErrorMessage(error)}`);
203
- }
204
- }
205
- else {
206
- this.logger?.warn(`Failed to get serviceUrl from service key store for ${destination}: ${getErrorMessage(error)}`);
207
- }
208
- }
209
- }
210
- if (!serviceUrl) {
211
- this.logger?.error(`Session for destination "${destination}" is missing required field 'serviceUrl'. SessionStore must contain initial session with serviceUrl${this.serviceKeyStore ? ' or serviceKeyStore must contain serviceUrl' : ''}.`);
212
- throw new Error(`Session for destination "${destination}" is missing required field 'serviceUrl'. ` +
213
- `SessionStore must contain initial session with serviceUrl${this.serviceKeyStore ? ' or serviceKeyStore must contain serviceUrl' : ''}.`);
214
- }
215
- return serviceUrl;
99
+ async getToken(destination) {
100
+ return this.obtain(destination, 'getTokens');
216
101
  }
217
102
  /**
218
- * Get UAA credentials from session or service key
103
+ * A new token for the destination, never the cached one — for a caller whose
104
+ * token the server has just refused. Calls the provider's `refreshTokens()`,
105
+ * writes the result to the session store and returns it.
219
106
  */
220
- async getAuthorizationConfigFromServiceKey(destination) {
221
- if (!this.serviceKeyStore) {
222
- throw new Error(`Authorization config not found for ${destination}. Session has no auth config and serviceKeyStore is not available.`);
223
- }
224
- let serviceKeyAuthConfig = null;
225
- try {
226
- serviceKeyAuthConfig =
227
- await this.serviceKeyStore.getAuthorizationConfig(destination);
107
+ async refreshToken(destination) {
108
+ return this.obtain(destination, 'refreshTokens');
109
+ }
110
+ async obtain(destination, method) {
111
+ const connConfig = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
112
+ const serviceUrl = await this.resolveServiceUrl(destination, connConfig);
113
+ const provider = await this.providerFor(destination, serviceUrl, connConfig);
114
+ this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
115
+ const result = await provider[method]();
116
+ if (!result?.authorizationToken) {
117
+ throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
228
118
  }
229
- catch (error) {
230
- if (hasErrorCode(error)) {
231
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
232
- this.logger?.debug(`Service key file not found for ${destination}: ${error.filePath || 'unknown path'}`);
233
- }
234
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
235
- this.logger?.warn(`Failed to parse service key for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
236
- }
237
- else {
238
- this.logger?.warn(`Failed to get authorization config from service key store for ${destination}: ${getErrorMessage(error)}`);
239
- }
240
- }
241
- else {
242
- this.logger?.warn(`Failed to get authorization config from service key store for ${destination}: ${getErrorMessage(error)}`);
243
- }
119
+ await this.persist(destination, serviceUrl, connConfig, result);
120
+ return result.authorizationToken;
121
+ }
122
+ async providerFor(destination, serviceUrl, connConfig) {
123
+ if (typeof this.provider !== 'function') {
124
+ return this.provider;
244
125
  }
245
- if (!serviceKeyAuthConfig) {
246
- throw new Error(`Authorization config not found for ${destination}. Session has no auth config${this.serviceKeyStore ? ' and serviceKeyStore has no auth config' : ' and serviceKeyStore is not available'}.`);
126
+ const existing = this.providers.get(destination);
127
+ if (existing) {
128
+ return existing;
247
129
  }
248
- return serviceKeyAuthConfig;
130
+ const authConfig = await this.resolveAuthorizationConfig(destination);
131
+ const built = this.provider(destination, authConfig, {
132
+ ...(connConfig ?? {}),
133
+ serviceUrl,
134
+ });
135
+ this.providers.set(destination, built);
136
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
137
+ hasCredentials: !!authConfig,
138
+ hasRefreshToken: !!authConfig?.refreshToken,
139
+ hasStoredToken: !!(connConfig?.authorizationToken || connConfig?.sessionCookies),
140
+ });
141
+ return built;
249
142
  }
250
143
  /**
251
- * Save token and config to session
144
+ * The credentials the provider is built with: the session's own when it holds
145
+ * them, else the service key's, carrying the refresh token the session
146
+ * stored. The session keeps a refresh token without credentials when the
147
+ * credentials came from the service key, since the broker does not copy the
148
+ * client secret into it; `loadSession` is where such a token is read.
252
149
  */
253
- async saveTokenToSession(destination, connectionConfig, authorizationConfig) {
254
- try {
255
- await this.sessionStore.setConnectionConfig(destination, connectionConfig);
256
- }
257
- catch (error) {
258
- this.logger?.error(`Failed to save connection config to session for ${destination}: ${getErrorMessage(error)}`);
259
- throw new Error(`Failed to save connection config for destination "${destination}": ${getErrorMessage(error)}`);
260
- }
261
- if (authorizationConfig.uaaUrl &&
262
- authorizationConfig.uaaClientId &&
263
- authorizationConfig.uaaClientSecret) {
264
- try {
265
- await this.sessionStore.setAuthorizationConfig(destination, authorizationConfig);
266
- }
267
- catch (error) {
268
- this.logger?.error(`Failed to save authorization config to session for ${destination}: ${getErrorMessage(error)}`);
269
- throw new Error(`Failed to save authorization config for destination "${destination}": ${getErrorMessage(error)}`);
270
- }
271
- }
272
- else {
273
- this.logger?.debug(`Skipping authorization config save for ${destination}: missing UAA fields`);
150
+ async resolveAuthorizationConfig(destination) {
151
+ const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
152
+ if (sessionAuth) {
153
+ return sessionAuth;
154
+ }
155
+ const session = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
156
+ const storedRefreshToken = typeof session?.refreshToken === 'string'
157
+ ? session.refreshToken
158
+ : undefined;
159
+ const serviceKeyStore = this.serviceKeyStore;
160
+ const keyAuth = serviceKeyStore
161
+ ? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
162
+ : null;
163
+ if (!keyAuth) {
164
+ return null;
274
165
  }
166
+ return {
167
+ ...keyAuth,
168
+ refreshToken: storedRefreshToken ?? keyAuth.refreshToken,
169
+ };
275
170
  }
276
- async requestTokens(destination, sourceLabel) {
277
- this.logger?.info(`[AuthBroker] Requesting tokens for ${destination} via ${sourceLabel}`);
278
- try {
279
- const getTokens = this.tokenProvider.getTokens;
280
- if (!getTokens) {
281
- throw new Error('AuthBroker: tokenProvider.getTokens is required');
282
- }
283
- const tokenResult = await getTokens.call(this.tokenProvider);
284
- const expiresAt = tokenResult.expiresIn
285
- ? Date.now() + tokenResult.expiresIn * 1000
286
- : undefined;
287
- this.logger?.info(`[AuthBroker] Tokens received for ${destination}`, {
288
- authorizationToken: (0, formatting_1.formatToken)(tokenResult.authorizationToken),
289
- hasRefreshToken: !!tokenResult.refreshToken,
290
- refreshToken: (0, formatting_1.formatToken)(tokenResult.refreshToken),
291
- authType: tokenResult.authType,
292
- expiresIn: tokenResult.expiresIn,
293
- expiresAt: expiresAt ? (0, formatting_1.formatExpirationDate)(expiresAt) : undefined,
294
- });
295
- return tokenResult;
296
- }
297
- catch (error) {
298
- if (hasErrorCode(error)) {
299
- if (error.code === 'VALIDATION_ERROR') {
300
- throw new Error(`Token provider validation failed for ${destination}: missing ${error.missingFields?.join(', ') || 'required fields'}`);
301
- }
302
- if (error.code === 'BROWSER_AUTH_ERROR') {
303
- throw new Error(`Token provider browser authentication failed for ${destination}: ${getErrorMessage(error)}`);
304
- }
305
- if (error.code === 'ECONNREFUSED' ||
306
- error.code === 'ETIMEDOUT' ||
307
- error.code === 'ENOTFOUND') {
308
- throw new Error(`Token provider network error for ${destination}: ${error.code}`);
309
- }
310
- if (error.code === 'SERVICE_KEY_ERROR') {
311
- throw new Error(`Token provider service key error for ${destination}: ${getErrorMessage(error)}`);
312
- }
313
- }
314
- throw new Error(`Token provider error for ${destination}: ${getErrorMessage(error)}`);
171
+ async resolveServiceUrl(destination, connConfig) {
172
+ let serviceUrl = connConfig?.serviceUrl;
173
+ const serviceKeyStore = this.serviceKeyStore;
174
+ if (!serviceUrl && serviceKeyStore) {
175
+ const keyConn = await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination));
176
+ serviceUrl = keyConn?.serviceUrl;
315
177
  }
316
- }
317
- async persistTokenResult(destination, serviceUrl, baseConnConfig, authConfig, tokenResult) {
318
- const token = tokenResult.authorizationToken;
319
- if (!token) {
320
- throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
178
+ if (!serviceUrl) {
179
+ throw new Error(`Session for destination "${destination}" is missing required field 'serviceUrl'. ` +
180
+ `SessionStore must contain initial session with serviceUrl${this.serviceKeyStore ? ' or serviceKeyStore must contain serviceUrl' : ''}.`);
321
181
  }
322
- const isSaml = tokenResult.tokenType === 'saml';
323
- const connectionConfigWithServiceUrl = {
324
- ...baseConnConfig,
325
- serviceUrl,
326
- authorizationToken: isSaml ? undefined : token,
327
- sessionCookies: isSaml ? token : undefined,
328
- authType: isSaml ? 'saml' : 'jwt',
329
- };
330
- const authorizationConfig = {
331
- ...authConfig,
332
- refreshToken: tokenResult.refreshToken ?? authConfig.refreshToken,
333
- };
334
- const expiresAt = tokenResult.expiresIn
335
- ? Date.now() + tokenResult.expiresIn * 1000
336
- : undefined;
337
- this.logger?.info(`[AuthBroker] Saving tokens to session for ${destination}`, {
338
- serviceUrl,
339
- authorizationToken: (0, formatting_1.formatToken)(token),
340
- hasRefreshToken: !!authorizationConfig.refreshToken,
341
- refreshToken: (0, formatting_1.formatToken)(authorizationConfig.refreshToken),
342
- expiresIn: tokenResult.expiresIn,
343
- expiresAt: expiresAt ? (0, formatting_1.formatExpirationDate)(expiresAt) : undefined,
344
- });
345
- await this.saveTokenToSession(destination, connectionConfigWithServiceUrl, authorizationConfig);
182
+ return serviceUrl;
346
183
  }
347
184
  /**
348
- * Get authentication token for destination.
349
- * Uses tokenProvider for all authentication operations (browser-based authorization).
350
- *
351
- * **Flow:**
352
- * **Step 0: Initialize Session with Token (if needed)**
353
- * - Check if session has `authorizationToken` AND UAA credentials
354
- * - If both are empty AND serviceKeyStore is available:
355
- * - Get UAA credentials from service key
356
- * - Use tokenProvider for browser-based authentication
357
- * - Save token and refresh token to session
185
+ * Writes the result by its type: a SAML result is session cookies, anything
186
+ * else a bearer token. The refresh token is written only when the result has
187
+ * one, so a provider that returns none does not erase the stored one.
358
188
  *
359
- * **Step 1: Token Validation**
360
- * - If token exists in session, validate it (if provider supports validation)
361
- * - If valid → return token
362
- * - If invalid or no token → continue to refresh
363
- *
364
- * **Step 2: Refresh Token Flow**
365
- * - Check if refresh token exists in session
366
- * - If refresh token exists:
367
- * - Use tokenProvider to refresh token (browser-based or refresh grant)
368
- * - Save new token to session
369
- * - Return new token
370
- * - Otherwise → proceed to Step 3
371
- *
372
- * **Step 3: New Token Flow**
373
- * - Get UAA credentials from session or service key
374
- * - Use tokenProvider for browser-based authentication
375
- * - Save new token to session
376
- * - Return new token
377
- *
378
- * **Important Notes:**
379
- * - All authentication is handled by tokenProvider (e.g., XSUAA provider)
380
- * - Provider uses browser-based authorization to ensure proper role assignment
381
- * - Direct UAA HTTP requests are not used to avoid role assignment issues
382
- *
383
- * @param destination Destination name (e.g., "TRIAL")
384
- * @returns Promise that resolves to JWT token string
385
- * @throws Error if session initialization fails or authentication failed
189
+ * `ITokenResult.expiresAt` has no field in `IConnectionConfig` to go to; the
190
+ * provider seeded with the stored token reads the expiry from the JWT itself.
386
191
  */
387
- async getToken(destination) {
388
- this.logger?.info(`[AuthBroker] Getting token for destination: ${destination}`);
389
- // Load session data
390
- const { connConfig, authConfig } = await this.loadSessionData(destination);
391
- // Get serviceUrl (required)
392
- const serviceUrl = await this.getServiceUrl(destination, connConfig);
393
- // Check if we have token or UAA credentials
394
- const sessionToken = connConfig?.authorizationToken || connConfig?.sessionCookies;
395
- const hasToken = !!sessionToken;
396
- const hasAuthConfig = !!authConfig;
397
- this.logger?.info(`[AuthBroker] Session check for ${destination}`, {
398
- hasToken,
399
- hasAuthConfig,
400
- hasServiceUrl: !!serviceUrl,
192
+ async persist(destination, serviceUrl, connConfig, result) {
193
+ const isSaml = result.tokenType === 'saml';
194
+ await this.sessionStore.setConnectionConfig(destination, {
195
+ ...(connConfig ?? {}),
401
196
  serviceUrl,
402
- authorizationToken: (0, formatting_1.formatToken)(connConfig?.authorizationToken || connConfig?.sessionCookies),
403
- hasRefreshToken: !!authConfig?.refreshToken,
404
- refreshToken: (0, formatting_1.formatToken)(authConfig?.refreshToken),
197
+ authorizationToken: isSaml ? undefined : result.authorizationToken,
198
+ sessionCookies: isSaml ? result.authorizationToken : undefined,
199
+ authType: isSaml ? 'saml' : 'jwt',
405
200
  });
406
- // Step 0: Initialize Session with Token (if needed)
407
- if (!hasToken && !hasAuthConfig) {
408
- if (!this.allowBrowserAuth) {
409
- const error = new Error(`Browser authentication required for destination "${destination}" but allowBrowserAuth is disabled. Either enable browser auth or provide a valid session with token.`);
410
- error.code = 'BROWSER_AUTH_REQUIRED';
411
- error.destination = destination;
412
- this.logger?.error(`Step 0: Browser auth required but disabled for ${destination}`);
413
- throw error;
414
- }
415
- const serviceKeyAuthConfig = await this.getAuthorizationConfigFromServiceKey(destination);
416
- const tokenResult = await this.requestTokens(destination, 'serviceKey');
417
- await this.persistTokenResult(destination, serviceUrl, connConfig, serviceKeyAuthConfig, tokenResult);
418
- this.logger?.info(`[AuthBroker] Token retrieved for ${destination} (initialized from service key)`, {
419
- authorizationToken: (0, formatting_1.formatToken)(tokenResult.authorizationToken),
420
- });
421
- return tokenResult.authorizationToken;
422
- }
423
- // Step 1: Request tokens via provider (provider handles token lifecycle internally)
424
- // Broker always calls provider.getTokens() - provider decides whether to return cached token,
425
- // refresh, or perform login. Consumer doesn't need to know about token issues.
426
- this.logger?.debug(`Step 1: Requesting tokens via provider for ${destination}`);
427
- let lastError = null;
428
- if (authConfig) {
429
- if (!this.allowBrowserAuth && !authConfig.refreshToken) {
430
- const error = new Error(`Browser authentication required for destination "${destination}" but allowBrowserAuth is disabled. Session has no refresh token.`);
431
- error.code = 'BROWSER_AUTH_REQUIRED';
432
- error.destination = destination;
433
- this.logger?.error(`Step 2: Browser auth required but disabled for ${destination}`);
434
- throw error;
435
- }
436
- try {
437
- const tokenResult = await this.requestTokens(destination, 'session');
438
- await this.persistTokenResult(destination, serviceUrl, connConfig, authConfig, tokenResult);
439
- this.logger?.info(`[AuthBroker] Token retrieved for ${destination} (via session)`, {
440
- authorizationToken: (0, formatting_1.formatToken)(tokenResult.authorizationToken),
201
+ if (result.refreshToken) {
202
+ const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
203
+ if (sessionAuth) {
204
+ // The session holds its own credentials: only the refresh token changes.
205
+ await this.sessionStore.setAuthorizationConfig(destination, {
206
+ ...sessionAuth,
207
+ refreshToken: result.refreshToken,
441
208
  });
442
- return tokenResult.authorizationToken;
443
- }
444
- catch (error) {
445
- lastError = error instanceof Error ? error : new Error(String(error));
446
- this.logger?.debug(`Step 2: Token request via session failed for ${destination}: ${getErrorMessage(error)}, trying service key`);
447
209
  }
448
- }
449
- if (!this.allowBrowserAuth) {
450
- const error = new Error(`Browser authentication required for destination "${destination}" but allowBrowserAuth is disabled. Token refresh via session failed and browser auth is not allowed. Either enable browser auth or ensure a valid refresh token exists in session.`);
451
- error.code = 'BROWSER_AUTH_REQUIRED';
452
- error.destination = destination;
453
- this.logger?.error(`Step 2: Browser auth required but disabled for ${destination}`);
454
- throw error;
455
- }
456
- if (!this.serviceKeyStore) {
457
- const tokenType = this.tokenProvider?.tokenType;
458
- if (tokenType === 'saml') {
459
- const tokenResult = await this.requestTokens(destination, 'session');
460
- await this.persistTokenResult(destination, serviceUrl, connConfig, authConfig || {}, tokenResult);
461
- this.logger?.info(`[AuthBroker] Token retrieved for ${destination} (SAML without auth config)`, {
462
- authorizationToken: (0, formatting_1.formatToken)(tokenResult.authorizationToken),
210
+ else {
211
+ // The credentials live in the service key and stay there. The session
212
+ // gets the refresh token alone — never the client secret.
213
+ const session = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
214
+ await this.sessionStore.saveSession(destination, {
215
+ ...(session ?? {}),
216
+ serviceUrl: session?.serviceUrl ?? serviceUrl,
217
+ refreshToken: result.refreshToken,
463
218
  });
464
- return tokenResult.authorizationToken;
465
- }
466
- if (lastError) {
467
- throw lastError;
468
219
  }
469
- throw new Error(`Authorization config not found for ${destination}. Session has no auth config and serviceKeyStore is not available.`);
470
- }
471
- // If the session attempt already performed an *interactive* browser login
472
- // and it failed (the user didn't complete it / it timed out), the serviceKey
473
- // strategy would call the SAME provider.getTokens() again and start a
474
- // duplicate browser login on the same redirect port — surfacing a misleading
475
- // "Port in use" instead of the real cause. Don't retry interactive failures;
476
- // propagate the original error. Transient/non-interactive session failures
477
- // still fall through to the serviceKey attempt below.
478
- if (lastError && isInteractiveAuthFailure(lastError)) {
479
- this.logger?.debug(`Step 2: session login failed interactively for ${destination}; not retrying via service key (${getErrorMessage(lastError)})`);
480
- throw lastError;
481
220
  }
482
- const serviceKeyAuthConfig = await this.getAuthorizationConfigFromServiceKey(destination);
483
- const tokenResult = await this.requestTokens(destination, 'serviceKey');
484
- await this.persistTokenResult(destination, serviceUrl, connConfig, serviceKeyAuthConfig, tokenResult);
485
- this.logger?.info(`[AuthBroker] Token retrieved for ${destination} (fallback to service key)`, {
486
- authorizationToken: (0, formatting_1.formatToken)(tokenResult.authorizationToken),
221
+ this.logger.info(`[AuthBroker] Token saved for ${destination}`, {
222
+ tokenType: result.tokenType ?? 'jwt',
223
+ authType: result.authType,
224
+ hasRefreshToken: !!result.refreshToken,
225
+ expiresAt: result.expiresAt
226
+ ? new Date(result.expiresAt).toISOString()
227
+ : undefined,
228
+ expiresIn: result.expiresIn,
487
229
  });
488
- return tokenResult.authorizationToken;
489
- }
490
- /**
491
- * Force refresh token for destination.
492
- * Uses refresh token from session if available, otherwise uses UAA credentials from session or service key.
493
- * @param destination Destination name (e.g., "TRIAL")
494
- * @returns Promise that resolves to new JWT token string
495
- */
496
- async refreshToken(destination) {
497
- this.logger?.debug(`Force refreshing token for destination: ${destination}`);
498
- // Call getToken to trigger full refresh flow
499
- return this.getToken(destination);
500
230
  }
501
231
  /**
502
- * Get authorization configuration for destination
503
- * @param destination Destination name (e.g., "TRIAL")
504
- * @returns Promise that resolves to IAuthorizationConfig or null if not found
232
+ * A store read where absence is an answer and anything else is not.
233
+ *
234
+ * A store says "nothing here" with `null`, or with `FILE_NOT_FOUND`, and the
235
+ * flow goes on to the next source. Any other failure — a service key that is
236
+ * not valid JSON, a file the process may not read — is a different problem
237
+ * with a different fix, and reaches the caller as the store raised it. It
238
+ * used to be logged and answered as absent, so the caller saw only the
239
+ * consequence ("missing required field 'serviceUrl'") and went looking for a
240
+ * file that was there.
505
241
  */
506
- async getAuthorizationConfig(destination) {
507
- this.logger?.debug(`Getting authorization config for ${destination}`);
508
- // Try session store first (has tokens)
509
- this.logger?.debug(`Checking session store for authorization config: ${destination}`);
510
- let sessionAuthConfig = null;
242
+ async read(destination, what, fn) {
511
243
  try {
512
- sessionAuthConfig =
513
- await this.sessionStore.getAuthorizationConfig(destination);
244
+ return await fn();
514
245
  }
515
246
  catch (error) {
516
- this.logger?.warn(`Failed to get authorization config from session store for ${destination}: ${getErrorMessage(error)}`);
517
- }
518
- if (sessionAuthConfig) {
519
- this.logger?.debug(`Authorization config from session for ${destination}: hasUaaUrl(${!!sessionAuthConfig.uaaUrl}), hasRefreshToken(${!!sessionAuthConfig.refreshToken})`);
520
- return sessionAuthConfig;
521
- }
522
- // Fall back to service key store (has UAA credentials) if available
523
- if (this.serviceKeyStore) {
524
- this.logger?.debug(`Checking service key store for authorization config: ${destination}`);
525
- let serviceKeyAuthConfig = null;
526
- try {
527
- serviceKeyAuthConfig =
528
- await this.serviceKeyStore.getAuthorizationConfig(destination);
529
- }
530
- catch (error) {
531
- // Handle typed store errors
532
- if (hasErrorCode(error)) {
533
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
534
- this.logger?.debug(`Service key file not found for ${destination}: ${error.filePath || 'unknown path'}`);
535
- }
536
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
537
- this.logger?.warn(`Failed to parse service key for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
538
- }
539
- else {
540
- this.logger?.warn(`Failed to get authorization config from service key store for ${destination}: ${getErrorMessage(error)}`);
541
- }
542
- }
543
- else {
544
- this.logger?.warn(`Failed to get authorization config from service key store for ${destination}: ${getErrorMessage(error)}`);
545
- }
546
- }
547
- if (serviceKeyAuthConfig) {
548
- this.logger?.debug(`Authorization config from service key for ${destination}: hasUaaUrl(${!!serviceKeyAuthConfig.uaaUrl})`);
549
- return serviceKeyAuthConfig;
247
+ if (errorCode(error) === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
248
+ this.logger.debug(`No ${what} for ${destination}: file not found`);
249
+ return null;
550
250
  }
251
+ throw error;
551
252
  }
552
- else {
553
- this.logger?.debug(`Service key store not available for ${destination}`);
554
- }
555
- this.logger?.debug(`No authorization config found for ${destination}`);
556
- return null;
557
253
  }
558
254
  /**
559
- * Get connection configuration for destination
560
- * @param destination Destination name (e.g., "TRIAL")
561
- * @returns Promise that resolves to IConnectionConfig or null if not found
255
+ * Authorization configuration for the destination: the session's, else the
256
+ * service key's, else null.
562
257
  */
563
- async getConnectionConfig(destination) {
564
- this.logger?.debug(`Getting connection config for ${destination}`);
565
- // Try session store first (has tokens and URLs)
566
- let sessionConnConfig = null;
567
- try {
568
- sessionConnConfig =
569
- await this.sessionStore.getConnectionConfig(destination);
570
- }
571
- catch (error) {
572
- this.logger?.warn(`Failed to get connection config from session store for ${destination}: ${getErrorMessage(error)}`);
258
+ async getAuthorizationConfig(destination) {
259
+ const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
260
+ if (sessionAuth) {
261
+ return sessionAuth;
573
262
  }
574
- if (sessionConnConfig) {
575
- const tokenLength = (sessionConnConfig.authorizationToken ||
576
- sessionConnConfig.sessionCookies)?.length || 0;
577
- const formattedToken = (0, formatting_1.formatToken)(sessionConnConfig.authorizationToken ||
578
- sessionConnConfig.sessionCookies);
579
- this.logger?.debug(`Connection config from session for ${destination}: token(${tokenLength} chars${formattedToken ? `, ${formattedToken}` : ''}), serviceUrl(${sessionConnConfig.serviceUrl ? 'yes' : 'no'})`);
580
- return sessionConnConfig;
263
+ const serviceKeyStore = this.serviceKeyStore;
264
+ if (!serviceKeyStore) {
265
+ return null;
581
266
  }
582
- // Fall back to service key store (has URLs but no tokens) if available
583
- if (this.serviceKeyStore) {
584
- let serviceKeyConnConfig = null;
585
- try {
586
- serviceKeyConnConfig =
587
- await this.serviceKeyStore.getConnectionConfig(destination);
588
- }
589
- catch (error) {
590
- // Handle typed store errors
591
- if (hasErrorCode(error)) {
592
- if (error.code === interfaces_auth_1.STORE_ERROR_CODES.FILE_NOT_FOUND) {
593
- this.logger?.debug(`Service key file not found for ${destination}: ${error.filePath || 'unknown path'}`);
594
- }
595
- else if (error.code === interfaces_auth_1.STORE_ERROR_CODES.PARSE_ERROR) {
596
- this.logger?.warn(`Failed to parse service key for ${destination}: ${error.filePath || 'unknown path'} - ${getErrorMessage(error)}`);
597
- }
598
- else {
599
- this.logger?.warn(`Failed to get connection config from service key store for ${destination}: ${getErrorMessage(error)}`);
600
- }
601
- }
602
- else {
603
- this.logger?.warn(`Failed to get connection config from service key store for ${destination}: ${getErrorMessage(error)}`);
604
- }
605
- }
606
- if (serviceKeyConnConfig) {
607
- this.logger?.debug(`Connection config from service key for ${destination}: serviceUrl(${serviceKeyConnConfig.serviceUrl ? 'yes' : 'no'}), token(none)`);
608
- return serviceKeyConnConfig;
609
- }
267
+ return this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination));
268
+ }
269
+ /**
270
+ * Connection configuration for the destination: the session's, else the
271
+ * service key's (which has URLs but no token), else null.
272
+ */
273
+ async getConnectionConfig(destination) {
274
+ const sessionConn = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
275
+ if (sessionConn) {
276
+ return sessionConn;
610
277
  }
611
- else {
612
- this.logger?.debug(`Service key store not available for ${destination}`);
278
+ const serviceKeyStore = this.serviceKeyStore;
279
+ if (!serviceKeyStore) {
280
+ return null;
613
281
  }
614
- this.logger?.debug(`No connection config found for ${destination}`);
615
- return null;
282
+ return this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination));
616
283
  }
617
284
  /**
618
- * Create a token refresher for a specific destination.
619
- *
620
- * The token refresher is designed to be injected into JwtAbapConnection via DI,
621
- * allowing the connection to handle token refresh transparently without knowing
622
- * about authentication internals.
623
- *
624
- * **Usage:**
625
- * ```typescript
626
- * const broker = new AuthBroker(config);
627
- * const tokenRefresher = broker.createTokenRefresher('TRIAL');
628
- * const connection = new JwtAbapConnection(config, tokenRefresher);
629
- * ```
630
- *
631
- * @param destination Destination name (e.g., "TRIAL")
632
- * @returns ITokenRefresher implementation for the given destination
285
+ * An `ITokenRefresher` for one destination, for injection into a connection:
286
+ * `getToken()` is the broker's `getToken`, `refreshToken()` its forced
287
+ * `refreshToken`.
633
288
  */
634
289
  createTokenRefresher(destination) {
635
- const broker = this;
636
290
  return {
637
- /**
638
- * Get current valid token.
639
- * Returns cached token if valid, otherwise refreshes and returns new token.
640
- */
641
- async getToken() {
642
- return broker.getToken(destination);
643
- },
644
- /**
645
- * Force refresh token and save to session store.
646
- * Always performs refresh, ignoring cached token validity.
647
- */
648
- async refreshToken() {
649
- return broker.refreshToken(destination);
650
- },
291
+ getToken: () => this.getToken(destination),
292
+ refreshToken: () => this.refreshToken(destination),
651
293
  };
652
294
  }
653
295
  }