@mcp-abap-adt/auth-broker 3.1.0 → 4.1.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +531 -0
  2. package/README.md +1062 -220
  3. package/dist/AuthBroker.d.ts +249 -30
  4. package/dist/AuthBroker.d.ts.map +1 -1
  5. package/dist/AuthBroker.js +681 -115
  6. package/dist/DestinationConfigError.d.ts +27 -0
  7. package/dist/DestinationConfigError.d.ts.map +1 -0
  8. package/dist/DestinationConfigError.js +36 -0
  9. package/dist/SessionWriter.d.ts +54 -0
  10. package/dist/SessionWriter.d.ts.map +1 -0
  11. package/dist/SessionWriter.js +135 -0
  12. package/dist/binding.d.ts +117 -0
  13. package/dist/binding.d.ts.map +1 -0
  14. package/dist/binding.js +223 -0
  15. package/dist/bindingOf.d.ts +37 -0
  16. package/dist/bindingOf.d.ts.map +1 -0
  17. package/dist/bindingOf.js +53 -0
  18. package/dist/clientAuthentication.d.ts +109 -0
  19. package/dist/clientAuthentication.d.ts.map +1 -0
  20. package/dist/clientAuthentication.js +218 -0
  21. package/dist/destinations.d.ts +164 -0
  22. package/dist/destinations.d.ts.map +1 -0
  23. package/dist/destinations.js +473 -0
  24. package/dist/index.d.ts +10 -4
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +14 -7
  27. package/dist/providers/ITokenProvider.d.ts +2 -1
  28. package/dist/providers/ITokenProvider.d.ts.map +1 -1
  29. package/dist/providers/index.d.ts +2 -2
  30. package/dist/providers/index.js +2 -2
  31. package/dist/stores/index.d.ts +2 -2
  32. package/dist/stores/index.js +2 -2
  33. package/dist/stores/interfaces.d.ts +4 -3
  34. package/dist/stores/interfaces.d.ts.map +1 -1
  35. package/dist/stores/interfaces.js +2 -2
  36. package/dist/types.d.ts +5 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/types.js +3 -1
  39. package/package.json +16 -8
@@ -1,6 +1,16 @@
1
1
  "use strict";
2
2
  /**
3
- * AuthBroker: tokens for a destination, from a provider, kept in a session store.
3
+ * AuthBroker: the credential for a destination, and tokens for one.
4
+ *
5
+ * `getProvider` builds the `IAuthProvider` a destination states, from the
6
+ * means its service key store holds and the secret its session store holds.
7
+ * Every token provider it builds writes what it obtains back to the session
8
+ * store through `onTokens` — the secret alone, retried by the broker when the
9
+ * store fails (`SessionWriter`); `flush()` reports what is still pending.
10
+ * The token API (`getToken`, `refreshToken`, `createTokenRefresher`) asks that
11
+ * same provider — one per destination, shared — or, when the consumer gives
12
+ * one, the consumer's provider, whose every answer it writes through the same
13
+ * path.
4
14
  *
5
15
  * The broker orchestrates and nothing more. It resolves what the stores know
6
16
  * about a destination, hands it to the provider, asks the provider for a token
@@ -12,12 +22,111 @@
12
22
  Object.defineProperty(exports, "__esModule", { value: true });
13
23
  exports.AuthBroker = void 0;
14
24
  const interfaces_auth_1 = require("@mcp-abap-adt/interfaces-auth");
25
+ const binding_1 = require("./binding");
26
+ const bindingOf_1 = require("./bindingOf");
27
+ const clientAuthentication_1 = require("./clientAuthentication");
28
+ const DestinationConfigError_1 = require("./DestinationConfigError");
29
+ const destinations_1 = require("./destinations");
30
+ const SessionWriter_1 = require("./SessionWriter");
15
31
  const noOpLogger = {
16
32
  info: () => { },
17
33
  error: () => { },
18
34
  warn: () => { },
19
35
  debug: () => { },
20
36
  };
37
+ /** The session secret's fields on a connection config — never means. */
38
+ /** The session's side: the secret and what it is bound to. */
39
+ const SECRET_FIELDS = [
40
+ 'authorizationToken',
41
+ 'sessionCookies',
42
+ 'expiresAt',
43
+ 'issuedFor',
44
+ 'issuedBy',
45
+ ];
46
+ const isSecretField = (field) => SECRET_FIELDS.includes(field);
47
+ /**
48
+ * The connection means a strategy-path seed carries — the connection's own,
49
+ * none of them a secret. Everything else a store's connection read answers
50
+ * (credentials, grant data, fields outside the type) stays behind.
51
+ */
52
+ const SEED_MEANS = [
53
+ 'serviceUrl',
54
+ 'sapClient',
55
+ 'language',
56
+ 'authType',
57
+ 'grantType',
58
+ ];
59
+ /**
60
+ * The token API factory's seed on the `clientAuthentication` strategy path,
61
+ * from an allowlist only: `SEED_MEANS`, and the session secret's own fields
62
+ * (`SECRET_FIELDS`: the token or cookies, the expiry, the binding) only when
63
+ * that same read is bound here — each secret judged by the read it came from.
64
+ * `serviceUrl` is the one resolved for the destination.
65
+ */
66
+ function strategySeed(connConfig, binding, serviceUrl) {
67
+ const source = (connConfig ?? {});
68
+ const fields = connConfig && (0, binding_1.boundHere)(connConfig, binding)
69
+ ? [...SEED_MEANS, ...SECRET_FIELDS]
70
+ : SEED_MEANS;
71
+ const seed = {};
72
+ for (const field of fields) {
73
+ if (source[field] !== undefined)
74
+ seed[field] = source[field];
75
+ }
76
+ return { ...seed, serviceUrl };
77
+ }
78
+ /** When the result expires, in epoch ms: its own `expiresAt`, else `expiresIn` from now. */
79
+ function expiryOf(result) {
80
+ if (typeof result.expiresAt === 'number')
81
+ return result.expiresAt;
82
+ if (typeof result.expiresIn === 'number') {
83
+ return Date.now() + Math.round(result.expiresIn * 1000);
84
+ }
85
+ return undefined;
86
+ }
87
+ /** A provider `getProvider` built that obtains tokens: a token provider. */
88
+ function obtainsTokens(provider) {
89
+ const candidate = provider;
90
+ return (typeof candidate.getTokens === 'function' &&
91
+ typeof candidate.refreshTokens === 'function');
92
+ }
93
+ /** The refresh token a stored session holds, unchecked. */
94
+ function storedRefreshTokenOf(session) {
95
+ return typeof session?.refreshToken === 'string'
96
+ ? session.refreshToken
97
+ : undefined;
98
+ }
99
+ /**
100
+ * The 3.x composition, the no-strategy path: the session's client as it is,
101
+ * else the key store's with `refreshToken` — the one given, else the key's
102
+ * own. (The strategy path takes `strategyAuthorization`.)
103
+ */
104
+ function composeAuthorization(read, refreshToken) {
105
+ if (read.sessionAuth)
106
+ return read.sessionAuth;
107
+ if (!read.keyAuth)
108
+ return null;
109
+ return {
110
+ ...read.keyAuth,
111
+ refreshToken: refreshToken ?? read.keyAuth.refreshToken,
112
+ };
113
+ }
114
+ /**
115
+ * The client on the `clientAuthentication` strategy path: the session's own
116
+ * client, else the key store's, carrying only `refreshToken` — the one the
117
+ * session read whose binding was checked holds, when bound — never a refresh
118
+ * token either client read carried itself.
119
+ */
120
+ function strategyAuthorization(read, refreshToken) {
121
+ // The same allowlist the strategy is told (`contextClient`): nothing else a
122
+ // store's authorization-config read carried reaches the factory.
123
+ const client = (0, clientAuthentication_1.contextClient)(read.sessionAuth ?? read.keyAuth);
124
+ return client ? { ...client, refreshToken } : null;
125
+ }
126
+ /** A stored string that counts as present: `''` is none. */
127
+ function present(value) {
128
+ return typeof value === 'string' && value !== '';
129
+ }
21
130
  function errorCode(error) {
22
131
  if (error !== null && typeof error === 'object' && 'code' in error) {
23
132
  const code = error.code;
@@ -25,6 +134,13 @@ function errorCode(error) {
25
134
  }
26
135
  return undefined;
27
136
  }
137
+ /** A result carrying a token, or the 3.x error for one that carries none. */
138
+ function checked(destination, result) {
139
+ if (!result?.authorizationToken) {
140
+ throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
141
+ }
142
+ return result;
143
+ }
28
144
  /**
29
145
  * AuthBroker manages authentication tokens for destinations
30
146
  */
@@ -33,7 +149,30 @@ class AuthBroker {
33
149
  serviceKeyStore;
34
150
  sessionStore;
35
151
  provider;
36
- providers = new Map();
152
+ /** A consumer factory's providers: the promise of the build, as `built`. */
153
+ consumerBuilt = new Map();
154
+ /**
155
+ * One cache per destination, shared by `getProvider` and — when the
156
+ * consumer gives no provider — the token API: the promise of the
157
+ * build, set before its first read.
158
+ */
159
+ built = new Map();
160
+ /**
161
+ * The write that failed for a result, keyed on the result the provider
162
+ * handed over and returns, then on the destination it was written
163
+ * for: the token API throws it to its caller, as 3.x did, while the broker
164
+ * keeps retrying. Dropped once a write of that result for that destination
165
+ * lands.
166
+ */
167
+ failedWrites = new WeakMap();
168
+ authorization;
169
+ oidcAuthorization;
170
+ deviceCodePresenter;
171
+ samlCookies;
172
+ assertionReplayStore;
173
+ clientAuthentication;
174
+ /** getProvider's writes of the session secret, retried on their own. */
175
+ writer;
37
176
  /**
38
177
  * @param config Stores and the provider (instance or factory)
39
178
  * @param logger Optional logger. Nothing the broker logs contains a token.
@@ -46,9 +185,6 @@ class AuthBroker {
46
185
  if (!sessionStore) {
47
186
  throw new Error('AuthBroker: sessionStore is required');
48
187
  }
49
- if (!provider) {
50
- throw new Error('AuthBroker: provider is required');
51
- }
52
188
  for (const method of [
53
189
  'getAuthorizationConfig',
54
190
  'getConnectionConfig',
@@ -61,7 +197,7 @@ class AuthBroker {
61
197
  throw new Error(`AuthBroker: sessionStore.${method} must be a function`);
62
198
  }
63
199
  }
64
- if (typeof provider !== 'function') {
200
+ if (provider && typeof provider !== 'function') {
65
201
  if (typeof provider.getTokens !== 'function') {
66
202
  throw new Error('AuthBroker: provider.getTokens must be a function');
67
203
  }
@@ -83,91 +219,303 @@ class AuthBroker {
83
219
  this.sessionStore = sessionStore;
84
220
  this.serviceKeyStore = serviceKeyStore;
85
221
  this.provider = provider;
222
+ this.authorization = config.authorization;
223
+ this.oidcAuthorization = config.oidcAuthorization;
224
+ this.deviceCodePresenter = config.deviceCodePresenter;
225
+ this.samlCookies = config.samlCookies;
226
+ this.assertionReplayStore = config.assertionReplayStore;
227
+ this.clientAuthentication = config.clientAuthentication;
86
228
  this.logger = logger ?? noOpLogger;
229
+ this.writer = new SessionWriter_1.SessionWriter(async (destination, bound) => {
230
+ try {
231
+ await this.writeSecret(destination, bound);
232
+ }
233
+ catch (error) {
234
+ // Per destination: one result object may be answered for several
235
+ // (an instance serves every destination), and a write that lands for
236
+ // one says nothing about another's.
237
+ let failures = this.failedWrites.get(bound.original);
238
+ if (!failures) {
239
+ failures = new Map();
240
+ this.failedWrites.set(bound.original, failures);
241
+ }
242
+ failures.set(destination, { error });
243
+ throw error;
244
+ }
245
+ this.failedWrites.get(bound.original)?.delete(destination);
246
+ }, this.logger);
87
247
  this.logger.debug('[AuthBroker] Broker initialized', {
88
248
  hasServiceKeyStore: !!serviceKeyStore,
89
- providerForm: typeof provider === 'function' ? 'factory' : 'instance',
249
+ providerForm: provider === undefined
250
+ ? 'none'
251
+ : typeof provider === 'function'
252
+ ? 'factory'
253
+ : 'instance',
90
254
  });
91
255
  }
92
256
  /**
93
257
  * A token for the destination: the provider's current one, which it refreshes
94
258
  * or obtains by login when it judges the cached one unusable.
95
259
  *
96
- * The result is written to the session store. Errors from the provider
97
- * (its typed errors included) propagate unchanged.
260
+ * Without a consumer `provider`, the provider is the one `getProvider` hands
261
+ * out for the destination — the same instance, so one token and one renewal
262
+ * serve both — and what it obtains is written by its `onTokens`. With one,
263
+ * the consumer's provider is asked and its answer written, as in 3.x — the
264
+ * secret alone.
265
+ *
266
+ * Errors from the provider (its typed errors included) propagate unchanged;
267
+ * so does a store failure, a failed write of the token included — the token
268
+ * stands and the broker keeps retrying the write.
269
+ *
270
+ * @throws DestinationConfigError for a destination the key store states as
271
+ * `basic` or `snc`, before any provider is asked; without a consumer
272
+ * `provider`, also for one whose provider obtains no token.
98
273
  */
99
274
  async getToken(destination) {
100
275
  return this.obtain(destination, 'getTokens');
101
276
  }
102
277
  /**
103
278
  * 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.
279
+ * token the server has just refused. Calls the provider's `refreshTokens()`;
280
+ * a renewal already in flight for the destination is joined, not repeated.
106
281
  */
107
282
  async refreshToken(destination) {
108
283
  return this.obtain(destination, 'refreshTokens');
109
284
  }
110
285
  async obtain(destination, method) {
286
+ const means = await this.statedForTokens(destination);
287
+ const result = this.provider
288
+ ? await this.obtainFromConsumer(destination, method, means)
289
+ : await this.obtainShared(destination, method, means);
290
+ return result.authorizationToken;
291
+ }
292
+ /**
293
+ * The destination's means, read first: a destination the key store states
294
+ * as `basic` or `snc` holds no token, so the token API refuses it before any
295
+ * provider is asked — it would write a token over a credential that is not
296
+ * one. A key store that states no `authType`, or no key store, is
297
+ * a 3.x setup, served as before.
298
+ */
299
+ async statedForTokens(destination) {
300
+ const serviceKeyStore = this.serviceKeyStore;
301
+ const means = serviceKeyStore
302
+ ? await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination))
303
+ : null;
304
+ const authType = means?.authType;
305
+ if (authType === 'basic' || authType === 'snc') {
306
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['authType'], `the token API serves no ${authType} destination: it holds no token`);
307
+ }
308
+ return means;
309
+ }
310
+ /** The token API on `getProvider`'s provider. */
311
+ async obtainShared(destination, method, means) {
312
+ if (!this.serviceKeyStore) {
313
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'serviceKeyStore'], 'the token API needs the provider option, or a service key store stating a token grant');
314
+ }
315
+ if ((means?.authType === 'jwt' || means?.authType === 'saml') &&
316
+ means.grantType === 'none') {
317
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider'], 'a none destination presents a handed-over credential and obtains no token: use getProvider, or give the token API the provider option');
318
+ }
319
+ const provider = await this.getProvider(destination);
320
+ if (!obtainsTokens(provider)) {
321
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider'], 'the destination states a credential that obtains no token: use getProvider, or give the token API the provider option');
322
+ }
323
+ this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
324
+ const result = checked(destination, await provider[method]());
325
+ // onTokens wrote it, or failed to: the failure is this caller's too.
326
+ this.throwFailedWrite(destination, result);
327
+ return result;
328
+ }
329
+ /**
330
+ * The token API on the consumer's provider, as 3.x: the session read first,
331
+ * then the key store, for `serviceUrl` and the client; every answer — a
332
+ * cache hit included — written, the secret alone, through the broker's one
333
+ * write path.
334
+ */
335
+ async obtainFromConsumer(destination, method, means) {
111
336
  const connConfig = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
112
337
  const serviceUrl = await this.resolveServiceUrl(destination, connConfig);
113
- const provider = await this.providerFor(destination, serviceUrl, connConfig);
338
+ const { provider, binding, carry } = await this.consumerProviderFor(destination, serviceUrl, connConfig, means);
114
339
  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}"`);
340
+ const result = checked(destination, await provider[method]());
341
+ await this.writer.submit(destination, {
342
+ result: { ...result, expiresAt: expiryOf(result) },
343
+ original: result,
344
+ binding,
345
+ carry,
346
+ });
347
+ this.throwFailedWrite(destination, result);
348
+ return result;
349
+ }
350
+ /** The failure recorded for this result's write here, thrown as the store raised it. */
351
+ throwFailedWrite(destination, result) {
352
+ const failed = this.failedWrites.get(result)?.get(destination);
353
+ if (failed) {
354
+ throw failed.error;
118
355
  }
119
- await this.persist(destination, serviceUrl, connConfig, result);
120
- return result.authorizationToken;
121
356
  }
122
- async providerFor(destination, serviceUrl, connConfig) {
123
- if (typeof this.provider !== 'function') {
124
- return this.provider;
125
- }
126
- const existing = this.providers.get(destination);
127
- if (existing) {
128
- return existing;
129
- }
130
- const authConfig = await this.resolveAuthorizationConfig(destination);
131
- const built = this.provider(destination, authConfig, {
132
- ...(connConfig ?? {}),
133
- serviceUrl,
357
+ /**
358
+ * The consumer's provider for the destination, cached per destination as a
359
+ * promise set before its first read and dropped when it throws, so
360
+ * the next call builds again: a factory's build — handed the client resolved
361
+ * then — or an instance as given, for every destination, handed no client.
362
+ * Either way the binding is fixed here, from the `serviceUrl`, SAP client and
363
+ * client of this first call, and kept with the provider.
364
+ */
365
+ consumerProviderFor(destination, serviceUrl, connConfig, means) {
366
+ const cached = this.consumerBuilt.get(destination);
367
+ if (cached) {
368
+ return cached;
369
+ }
370
+ const provider = this.provider;
371
+ const sapClient = present(connConfig?.sapClient)
372
+ ? connConfig.sapClient
373
+ : means?.sapClient;
374
+ const build = Promise.resolve().then(async () => {
375
+ if (typeof provider !== 'function') {
376
+ return {
377
+ provider,
378
+ binding: (0, binding_1.consumerBinding)(serviceUrl, sapClient, null),
379
+ carry: 'any',
380
+ };
381
+ }
382
+ const read = await this.readAuthorization(destination);
383
+ const strategic = await this.consumerClientAuthentication(destination, means,
384
+ // The strategy is told the client, never a refresh token read with it.
385
+ strategyAuthorization(read, undefined));
386
+ let built;
387
+ let client;
388
+ let binding;
389
+ let carry;
390
+ if (strategic) {
391
+ // The strategy's answer and the client identity, resolved before the
392
+ // factory; the factory called inside the same guard: a throw is fixed
393
+ // words, nothing of the thrown value, no cause. The stored refresh
394
+ // token is carried only when the session is bound to this resource
395
+ // and this client identity — never to another authorization server.
396
+ binding = (0, binding_1.strategyBinding)((0, binding_1.consumerBinding)(serviceUrl, sapClient, strategic.identity));
397
+ const session = read.sessionRead
398
+ ? read.session
399
+ : await this.loadStoredSession(destination);
400
+ const bound = !!session && (0, binding_1.boundHere)(session, binding);
401
+ const boundRefreshToken = bound
402
+ ? storedRefreshTokenOf(session)
403
+ : undefined;
404
+ // The client without any refresh token of its own read: a session
405
+ // store's `getAuthorizationConfig` is another read than the session
406
+ // whose binding was checked, and another process may have written
407
+ // between them. The refresh token comes only from that checked read.
408
+ client = strategyAuthorization(read, boundRefreshToken);
409
+ // Each stored secret is judged by the read it came from: the seed's
410
+ // token, cookies and expiry by the binding the connection read itself
411
+ // carries — another process may have written between the two reads —
412
+ // else only the means seed the provider.
413
+ const seed = strategySeed(connConfig, binding, serviceUrl);
414
+ carry = 'bound';
415
+ const refreshToken = client ? client.refreshToken : boundRefreshToken;
416
+ const fourth = {
417
+ clientAuthentication: strategic.clientAuthentication,
418
+ ...(strategic.identity
419
+ ? {
420
+ uaaUrl: strategic.identity.uaaUrl,
421
+ clientId: strategic.identity.uaaClientId,
422
+ }
423
+ : {}),
424
+ ...(present(refreshToken) ? { refreshToken } : {}),
425
+ };
426
+ try {
427
+ built = provider(destination, client, seed, fourth);
428
+ }
429
+ catch {
430
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'clientAuthentication'], 'the token provider factory failed beside the clientAuthentication strategy');
431
+ }
432
+ }
433
+ else {
434
+ // 4.0.0: the stored secret and refresh token carried over as read.
435
+ client = composeAuthorization(read, storedRefreshTokenOf(read.session));
436
+ binding = (0, binding_1.consumerBinding)(serviceUrl, sapClient, client);
437
+ carry = 'any';
438
+ built = provider(destination, client, {
439
+ ...(connConfig ?? {}),
440
+ serviceUrl,
441
+ });
442
+ }
443
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
444
+ hasCredentials: !!client,
445
+ hasRefreshToken: !!client?.refreshToken,
446
+ hasStoredToken: !!(connConfig?.authorizationToken || connConfig?.sessionCookies),
447
+ });
448
+ return { provider: built, binding, carry };
134
449
  });
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),
450
+ this.consumerBuilt.set(destination, build);
451
+ build.catch(() => {
452
+ if (this.consumerBuilt.get(destination) === build) {
453
+ this.consumerBuilt.delete(destination);
454
+ }
140
455
  });
141
- return built;
456
+ return build;
457
+ }
458
+ /**
459
+ * The token API's strategy path: with a `clientAuthentication` strategy and a
460
+ * destination stating a grant whose client authenticates, the strategy's
461
+ * answer — resolved inside the guard — and the client identity: the secret
462
+ * client, else the certificate client's, read through the context's memoised
463
+ * `readCertificate`. `null` without a strategy (4.0.0: nothing
464
+ * certificate-related is asked) and for a grant that authenticates no
465
+ * client (`saml2_pure`, `none`), as `getProvider` decides.
466
+ *
467
+ * The strategy is told the grant, so a destination that states none is
468
+ * refused before it is called rather than built without it. (An instance
469
+ * given as `provider` is the consumer's own composition: the strategy
470
+ * applies only to what the broker builds, so it is not used there.)
471
+ */
472
+ async consumerClientAuthentication(destination, means, client) {
473
+ const strategy = this.clientAuthentication;
474
+ if (!strategy) {
475
+ return null;
476
+ }
477
+ // basic and snc never get here: the token API refuses them first
478
+ // (`statedForTokens`).
479
+ if (!means || (means.authType !== 'jwt' && means.authType !== 'saml')) {
480
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['authType', 'grantType'], 'a clientAuthentication strategy is told the grant: the destination must state its authType and grantType');
481
+ }
482
+ const grant = (0, destinations_1.statedGrant)(destination, means.authType, means);
483
+ if (grant === 'saml2_pure' || grant === 'none') {
484
+ return null;
485
+ }
486
+ const serviceKeyStore = this.serviceKeyStore;
487
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, client, () => this.read(destination, 'client certificate', async () => serviceKeyStore?.getClientCertificate
488
+ ? serviceKeyStore.getClientCertificate(destination)
489
+ : null));
490
+ const clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(strategy, context);
491
+ return { clientAuthentication, identity: await (0, clientAuthentication_1.clientIdentity)(context) };
142
492
  }
143
493
  /**
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.
494
+ * What a consumer's factory's credentials are composed from, read in the
495
+ * 3.x order: the session's authorization config; only when it has none, the
496
+ * session and then the key store's client. A session store of auth-stores 3
497
+ * answers no client, so the key store's is what is found.
149
498
  */
150
- async resolveAuthorizationConfig(destination) {
499
+ async readAuthorization(destination) {
151
500
  const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
152
501
  if (sessionAuth) {
153
- return sessionAuth;
502
+ return { sessionAuth, keyAuth: null, session: null, sessionRead: false };
154
503
  }
155
- const session = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
156
- const storedRefreshToken = typeof session?.refreshToken === 'string'
157
- ? session.refreshToken
158
- : undefined;
504
+ const session = await this.loadStoredSession(destination);
159
505
  const serviceKeyStore = this.serviceKeyStore;
160
506
  const keyAuth = serviceKeyStore
161
507
  ? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
162
508
  : null;
163
- if (!keyAuth) {
164
- return null;
165
- }
166
- return {
167
- ...keyAuth,
168
- refreshToken: storedRefreshToken ?? keyAuth.refreshToken,
169
- };
509
+ return { sessionAuth: null, keyAuth, session, sessionRead: true };
170
510
  }
511
+ /** The session the store holds for the destination, or null. */
512
+ loadStoredSession(destination) {
513
+ return this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
514
+ }
515
+ /**
516
+ * `serviceUrl` for a consumer's provider, in the 3.x order: the session's,
517
+ * else the key store's, else an error before the provider is asked.
518
+ */
171
519
  async resolveServiceUrl(destination, connConfig) {
172
520
  let serviceUrl = connConfig?.serviceUrl;
173
521
  const serviceKeyStore = this.serviceKeyStore;
@@ -181,53 +529,6 @@ class AuthBroker {
181
529
  }
182
530
  return serviceUrl;
183
531
  }
184
- /**
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.
188
- *
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.
191
- */
192
- async persist(destination, serviceUrl, connConfig, result) {
193
- const isSaml = result.tokenType === 'saml';
194
- await this.sessionStore.setConnectionConfig(destination, {
195
- ...(connConfig ?? {}),
196
- serviceUrl,
197
- authorizationToken: isSaml ? undefined : result.authorizationToken,
198
- sessionCookies: isSaml ? result.authorizationToken : undefined,
199
- authType: isSaml ? 'saml' : 'jwt',
200
- });
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,
208
- });
209
- }
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,
218
- });
219
- }
220
- }
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,
229
- });
230
- }
231
532
  /**
232
533
  * A store read where absence is an answer and anything else is not.
233
534
  *
@@ -252,34 +553,299 @@ class AuthBroker {
252
553
  }
253
554
  }
254
555
  /**
255
- * Authorization configuration for the destination: the session's, else the
256
- * service key's, else null.
556
+ * The destination's client: the service key store's `uaaUrl`, `uaaClientId`
557
+ * and `uaaClientSecret`, with the refresh token the session stores laid over
558
+ * them; `null` when the key store has no client. A client a session store
559
+ * answers is not read, nor a refresh token a key store answers.
257
560
  */
258
561
  async getAuthorizationConfig(destination) {
259
- const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
260
- if (sessionAuth) {
261
- return sessionAuth;
262
- }
263
562
  const serviceKeyStore = this.serviceKeyStore;
264
- if (!serviceKeyStore) {
563
+ const client = serviceKeyStore
564
+ ? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
565
+ : null;
566
+ if (!client) {
265
567
  return null;
266
568
  }
267
- return this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination));
569
+ const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
570
+ const composed = {
571
+ uaaUrl: client.uaaUrl,
572
+ uaaClientId: client.uaaClientId,
573
+ uaaClientSecret: client.uaaClientSecret,
574
+ };
575
+ if (secret?.refreshToken !== undefined) {
576
+ composed.refreshToken = secret.refreshToken;
577
+ }
578
+ return composed;
268
579
  }
269
580
  /**
270
- * Connection configuration for the destination: the session's, else the
271
- * service key's (which has URLs but no token), else null.
581
+ * The destination's connection config: the service key store's means with
582
+ * the session's secret (`authorizationToken`, `sessionCookies`,
583
+ * `expiresAt`) and its binding (`issuedFor`, `issuedBy`) laid over them; `null` when neither store holds anything.
584
+ * Means a session store answers are not read, nor a secret a key store
585
+ * answers.
272
586
  */
273
587
  async getConnectionConfig(destination) {
274
- const sessionConn = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
275
- if (sessionConn) {
276
- return sessionConn;
588
+ const serviceKeyStore = this.serviceKeyStore;
589
+ const means = serviceKeyStore
590
+ ? await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination))
591
+ : null;
592
+ const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
593
+ const composed = {};
594
+ for (const [field, value] of Object.entries(means ?? {})) {
595
+ if (!isSecretField(field) && value !== undefined) {
596
+ composed[field] = value;
597
+ }
598
+ }
599
+ for (const field of SECRET_FIELDS) {
600
+ const value = secret?.[field];
601
+ if (value !== undefined) {
602
+ composed[field] = value;
603
+ }
604
+ }
605
+ return Object.keys(composed).length > 0 ? composed : null;
606
+ }
607
+ /**
608
+ * The credential for the destination, ready for a connector: the provider
609
+ * its configuration states (`authType`, and `grantType` for `jwt` / `saml`),
610
+ * built from the means the service key store holds and the secret the
611
+ * session store holds.
612
+ *
613
+ * One provider per destination for the broker's life: concurrent first calls
614
+ * share one build, and a build that threw is tried again on the next call.
615
+ *
616
+ * A token provider (the UAA, OIDC and SAML grants) is seeded from the
617
+ * session when the stored secret is bound to this destination, and writes
618
+ * every token it obtains — at `prepare()`, on expiry, or in `rejected()` after
619
+ * a 401 — back to the session store before it answers (see `flush()`).
620
+ *
621
+ * @throws DestinationConfigError when the destination lacks what its type
622
+ * needs — naming the fields or options, never a value.
623
+ */
624
+ getProvider(destination) {
625
+ const cached = this.built.get(destination);
626
+ if (cached) {
627
+ return cached;
277
628
  }
629
+ // Deferred by one tick, so the promise is in the cache before the build
630
+ // reads anything.
631
+ const build = Promise.resolve().then(() => this.build(destination));
632
+ this.built.set(destination, build);
633
+ build.catch(() => {
634
+ if (this.built.get(destination) === build) {
635
+ this.built.delete(destination);
636
+ }
637
+ });
638
+ return build;
639
+ }
640
+ async build(destination) {
278
641
  const serviceKeyStore = this.serviceKeyStore;
279
642
  if (!serviceKeyStore) {
280
- return null;
643
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['serviceKeyStore'], 'getProvider reads the means from a service key store, and none was given');
644
+ }
645
+ const means = await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination));
646
+ const authType = (0, destinations_1.statedAuthType)(destination, means);
647
+ // statedAuthType refuses a destination without means.
648
+ const stated = means;
649
+ let provider;
650
+ if (authType === 'basic') {
651
+ provider = (0, destinations_1.basicProvider)(destination, stated);
652
+ }
653
+ else if (authType === 'snc') {
654
+ provider = (0, destinations_1.sncProvider)(destination, stated, this.logger);
655
+ }
656
+ else {
657
+ const grant = (0, destinations_1.statedGrant)(destination, authType, stated);
658
+ if (grant !== 'none') {
659
+ // A grant that obtains a secret: the row's client (none for
660
+ // saml2_pure), what a secret for this destination is bound to
661
+ // — written with every secret, and required of a stored one
662
+ // before it seeds — and the stored secret when it is.
663
+ const secretClient = grant === 'saml2_pure'
664
+ ? null
665
+ : await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination));
666
+ // The consumer's client authentication, resolved inside the guard
667
+ // before any provider exists: a strategy that throws leaves nothing
668
+ // built and nothing cached. Then the client identity the row and the
669
+ // binding take — the secret client, else the certificate client's —
670
+ // so the binding and the seed are chosen from it. Without a strategy
671
+ // nothing of this runs: 4.0.0's client, nothing certificate-related.
672
+ let client = secretClient;
673
+ let clientAuthentication;
674
+ if (this.clientAuthentication && grant !== 'saml2_pure') {
675
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, secretClient, () => this.read(destination, 'client certificate', async () => serviceKeyStore.getClientCertificate
676
+ ? serviceKeyStore.getClientCertificate(destination)
677
+ : null));
678
+ clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(this.clientAuthentication, context);
679
+ client = await (0, clientAuthentication_1.clientIdentity)(context);
680
+ }
681
+ const computed = (0, bindingOf_1.destinationBinding)(authType, grant, stated, client);
682
+ // The strategy path: a resource neither side states matches.
683
+ const binding = clientAuthentication
684
+ ? (0, binding_1.strategyBinding)(computed)
685
+ : computed;
686
+ const stored = grant === 'client_credentials'
687
+ ? null
688
+ : await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
689
+ const secret = this.boundOrDiscarded(destination, stored, binding);
690
+ // The expiry is fixed when the result arrives, not when a retry
691
+ // finally writes it — on a copy; the outcome of the write is recorded
692
+ // against the result itself, which the provider also returns to the
693
+ // token API.
694
+ const onTokens = (result) => this.writer.submit(destination, {
695
+ result: { ...result, expiresAt: expiryOf(result) },
696
+ original: result,
697
+ binding,
698
+ carry: 'bound',
699
+ });
700
+ const common = {
701
+ destination,
702
+ client,
703
+ secret,
704
+ ...(clientAuthentication ? { clientAuthentication } : {}),
705
+ logger: this.logger,
706
+ onTokens,
707
+ };
708
+ if ((0, destinations_1.isUaaGrant)(grant)) {
709
+ provider = (0, destinations_1.uaaProvider)({
710
+ ...common,
711
+ grant,
712
+ authorization: this.authorization,
713
+ });
714
+ }
715
+ else if ((0, destinations_1.isOidcGrant)(grant)) {
716
+ provider = (0, destinations_1.oidcProvider)({
717
+ ...common,
718
+ grant,
719
+ means: stated,
720
+ oidcAuthorization: this.oidcAuthorization,
721
+ deviceCodePresenter: this.deviceCodePresenter,
722
+ });
723
+ }
724
+ else if ((0, destinations_1.isSamlGrant)(grant)) {
725
+ provider = (0, destinations_1.samlProvider)({
726
+ ...common,
727
+ grant,
728
+ means: stated,
729
+ authorization: this.authorization,
730
+ samlCookies: this.samlCookies,
731
+ assertionReplayStore: this.assertionReplayStore,
732
+ });
733
+ }
734
+ else {
735
+ // statedGrant admits only the allowed `authType` / `grantType` pairs.
736
+ throw new Error(`unreachable grant ${grant}`);
737
+ }
738
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
739
+ authType,
740
+ grant,
741
+ seeded: !!secret,
742
+ });
743
+ return provider;
744
+ }
745
+ const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
746
+ const client = authType === 'jwt'
747
+ ? await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination))
748
+ : null;
749
+ provider = (0, destinations_1.handedOverProvider)(destination, authType, secret, (0, bindingOf_1.destinationBinding)(authType, 'none', stated, client));
750
+ }
751
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
752
+ authType,
753
+ });
754
+ return provider;
755
+ }
756
+ /**
757
+ * The stored secret when it is bound to this destination's resource and
758
+ * issuer, else `null`: the provider is then built as with no session
759
+ * and logs in afresh — the refresh token is not spent either. The log line
760
+ * names the destination only: never a URI, never a token.
761
+ */
762
+ boundOrDiscarded(destination, stored, binding) {
763
+ if (!stored || (0, binding_1.boundHere)(stored, binding))
764
+ return stored;
765
+ if (present(stored.authorizationToken) ||
766
+ present(stored.sessionCookies) ||
767
+ present(stored.refreshToken)) {
768
+ this.logger.warn(`[AuthBroker] ${destination}: secret bound to another resource, discarded`);
769
+ }
770
+ return null;
771
+ }
772
+ /**
773
+ * Waits for the session writes still pending — each gets one more attempt —
774
+ * and rejects naming the destinations whose store still refuses. Call it on
775
+ * shutdown to know whether every token a provider obtained is stored; the
776
+ * broker keeps retrying after a rejection.
777
+ */
778
+ flush() {
779
+ return this.writer.flush();
780
+ }
781
+ /**
782
+ * One write of the destination's session secret — and nothing else:
783
+ * `{ authorizationToken, expiresAt, refreshToken, issuedFor, issuedBy }` in
784
+ * one `saveSession` — or, for a `saml2_pure` result (`tokenType: 'saml'`),
785
+ * `{ sessionCookies, expiresAt, issuedFor, issuedBy }`: cookies are written
786
+ * as cookies, every other result as a token (`saml2_bearer` included). No means is ever written: not `serviceUrl`, not
787
+ * `authType`, not the client — they live in the key store, which the broker
788
+ * never writes.
789
+ *
790
+ * - `expiresAt`: fixed by `onTokens` when the result arrived — the result's
791
+ * own, else the `expiresIn` it reports counted from then (the rule the
792
+ * provider applies to its own cache).
793
+ * - `refreshToken`: the result's, else the one the session holds, read at
794
+ * write time, so a result without one does not erase the stored one — for
795
+ * a provider the broker built, only a stored one bound where this one is:
796
+ * a refresh token obtained for another resource or from another client is
797
+ * not carried into this secret. So too for a consumer's factory beside a
798
+ * `clientAuthentication` strategy, seeded only with a bound session. A
799
+ * consumer's provider without one was seeded with whatever the session
800
+ * held, so whatever it holds is carried (4.0.0).
801
+ * - `issuedFor` / `issuedBy`: the binding computed when the provider was
802
+ * built, each left out when the means lack its source — so the
803
+ * store clears it.
804
+ * - A destination the key store now states as `basic` or `snc` is not
805
+ * written: those obtain no session secret.
806
+ */
807
+ async writeSecret(destination, { result, binding, carry }) {
808
+ const serviceKeyStore = this.serviceKeyStore;
809
+ const means = serviceKeyStore
810
+ ? await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination))
811
+ : null;
812
+ if (means?.authType === 'basic' || means?.authType === 'snc') {
813
+ this.logger.warn(`[AuthBroker] Not written: ${destination} is a ${means.authType} destination, which holds no session secret`);
814
+ return;
815
+ }
816
+ let secret;
817
+ if (result.tokenType === 'saml') {
818
+ // saml2_pure: the cookies are the credential, and SAML has no refresh
819
+ // token to carry.
820
+ secret = {
821
+ sessionCookies: result.authorizationToken,
822
+ expiresAt: result.expiresAt,
823
+ };
281
824
  }
282
- return this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination));
825
+ else {
826
+ const stored = result.refreshToken
827
+ ? null
828
+ : await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
829
+ const storedRefreshToken = present(stored?.refreshToken) &&
830
+ (carry === 'any' || (0, binding_1.boundHere)(stored, binding))
831
+ ? stored.refreshToken
832
+ : undefined;
833
+ secret = {
834
+ authorizationToken: result.authorizationToken,
835
+ expiresAt: result.expiresAt,
836
+ refreshToken: result.refreshToken || storedRefreshToken,
837
+ };
838
+ }
839
+ if (binding.issuedFor !== undefined)
840
+ secret.issuedFor = binding.issuedFor;
841
+ if (binding.issuedBy !== undefined)
842
+ secret.issuedBy = binding.issuedBy;
843
+ await this.sessionStore.saveSession(destination, secret);
844
+ this.logger.info(`[AuthBroker] Session secret saved for ${destination}`, {
845
+ credential: secret.sessionCookies !== undefined ? 'cookies' : 'token',
846
+ hasRefreshToken: !!secret.refreshToken,
847
+ expiresAt: result.expiresAt,
848
+ });
283
849
  }
284
850
  /**
285
851
  * An `ITokenRefresher` for one destination, for injection into a connection: