@mcp-abap-adt/auth-broker 4.0.0 → 5.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.
@@ -4,13 +4,20 @@
4
4
  *
5
5
  * `getProvider` builds the `IAuthProvider` a destination states, from the
6
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.
7
+ * Every token provider it builds renews through the consumer's `renewal` and
8
+ * writes what it obtains back to the session store through auth-providers'
9
+ * `refreshStatePersistence` — the secret alone, through one plain queue per
10
+ * destination (`SessionWriter`): a failed write stays pending until the
11
+ * destination's next write or `flush()`, and means what the consumer's
12
+ * `onWriteFailure` says; `flush()` reports what is still pending.
10
13
  * 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.
14
+ * same provider — the row path's — or, when the consumer gives one, the
15
+ * consumer's provider — the consumer path's, resolved, cached and bound on its
16
+ * own — whose every answer it writes through the same write path.
17
+ *
18
+ * A provider is never changed: every call re-reads what its path's
19
+ * provider was built from and builds a new one, starting with nothing, when
20
+ * anything of it changed (`buildIdentity.ts`).
14
21
  *
15
22
  * The broker orchestrates and nothing more. It resolves what the stores know
16
23
  * about a destination, hands it to the provider, asks the provider for a token
@@ -21,18 +28,58 @@
21
28
  */
22
29
  Object.defineProperty(exports, "__esModule", { value: true });
23
30
  exports.AuthBroker = void 0;
31
+ const auth_errors_1 = require("@mcp-abap-adt/auth-errors");
24
32
  const interfaces_auth_1 = require("@mcp-abap-adt/interfaces-auth");
25
33
  const binding_1 = require("./binding");
26
34
  const bindingOf_1 = require("./bindingOf");
35
+ const buildIdentity_1 = require("./buildIdentity");
36
+ const clientAuthentication_1 = require("./clientAuthentication");
37
+ const contractShape_1 = require("./contractShape");
27
38
  const DestinationConfigError_1 = require("./DestinationConfigError");
28
39
  const destinations_1 = require("./destinations");
40
+ const quietLogger_1 = require("./quietLogger");
29
41
  const SessionWriter_1 = require("./SessionWriter");
30
- const noOpLogger = {
31
- info: () => { },
32
- error: () => { },
33
- warn: () => { },
34
- debug: () => { },
35
- };
42
+ const DROPPED = Object.freeze({ written: false });
43
+ /** `work`'s result or what it threw, as a frozen outcome; never rejects. */
44
+ async function outcomeOf(work) {
45
+ try {
46
+ return Object.freeze({ ok: true, value: await work() });
47
+ }
48
+ catch (thrown) {
49
+ return Object.freeze({ ok: false, thrown });
50
+ }
51
+ }
52
+ /**
53
+ * Joins `slot` with `start`'s work and the caller's signal, and unwraps the
54
+ * outcome outside `join`: the value, or the very value the work threw.
55
+ */
56
+ async function joined(slot, work, signal) {
57
+ const outcome = await slot.join((attempt) => outcomeOf(() => work(attempt)), signal);
58
+ if (!outcome.ok)
59
+ throw outcome.thrown;
60
+ return outcome.value;
61
+ }
62
+ /**
63
+ * One wait of one caller — on the write queue, or a store read outside a
64
+ * resolution — as a waiter of a slot of its own: the caller's signal
65
+ * releases that caller only, and the work runs on.
66
+ */
67
+ function waitFor(work, signal) {
68
+ return joined((0, auth_errors_1.sharedAttempt)('persisting-tokens'), work, signal);
69
+ }
70
+ /**
71
+ * Rejects `aborted` when the caller's signal has aborted by now — after its
72
+ * last wait, before its success — through the same one implementation: a
73
+ * waiter that joins with an aborted signal is refused at once.
74
+ */
75
+ async function stillWanted(signal) {
76
+ if (signal !== undefined)
77
+ await waitFor(async () => undefined, signal);
78
+ }
79
+ /** The provider a token API call is answered by, asked with the call's signal. */
80
+ function tokenOptions(signal) {
81
+ return signal === undefined ? [] : [{ signal }];
82
+ }
36
83
  /** The session secret's fields on a connection config — never means. */
37
84
  /** The session's side: the secret and what it is bound to. */
38
85
  const SECRET_FIELDS = [
@@ -43,6 +90,33 @@ const SECRET_FIELDS = [
43
90
  'issuedBy',
44
91
  ];
45
92
  const isSecretField = (field) => SECRET_FIELDS.includes(field);
93
+ /**
94
+ * The connection means a strategy-path seed carries — the connection's own,
95
+ * none of them a secret. Everything else a store's connection read answers
96
+ * (credentials, grant data, fields outside the type) stays behind.
97
+ */
98
+ const SEED_MEANS = [
99
+ 'serviceUrl',
100
+ 'sapClient',
101
+ 'language',
102
+ 'authType',
103
+ 'grantType',
104
+ ];
105
+ /**
106
+ * The token API factory's seed: the connection means from an allowlist
107
+ * (`SEED_MEANS`) and nothing else — never the session secret's own fields
108
+ * (`SECRET_FIELDS`), whatever the stored record says: the consumer path is
109
+ * never seeded. `serviceUrl` is the one resolved for the destination.
110
+ */
111
+ function consumerSeed(connConfig, serviceUrl) {
112
+ const source = (connConfig ?? {});
113
+ const seed = {};
114
+ for (const field of SEED_MEANS) {
115
+ if (source[field] !== undefined)
116
+ seed[field] = source[field];
117
+ }
118
+ return { ...seed, serviceUrl };
119
+ }
46
120
  /** When the result expires, in epoch ms: its own `expiresAt`, else `expiresIn` from now. */
47
121
  function expiryOf(result) {
48
122
  if (typeof result.expiresAt === 'number')
@@ -52,12 +126,68 @@ function expiryOf(result) {
52
126
  }
53
127
  return undefined;
54
128
  }
129
+ /**
130
+ * The refresh token a build owns, after one reported refresh token: a
131
+ * string written makes it owned; `null` (a discard) writes `''` and makes it
132
+ * none; `undefined` writes the owned one, or `''` when the build owns none.
133
+ */
134
+ function ownedAfter(owned, reported) {
135
+ if (present(reported))
136
+ return { written: reported, owned: reported };
137
+ if (reported === null)
138
+ return { written: '', owned: undefined };
139
+ return { written: owned ?? '', owned };
140
+ }
141
+ /** One map per path. */
142
+ function perPath() {
143
+ return { row: new Map(), consumer: new Map() };
144
+ }
55
145
  /** A provider `getProvider` built that obtains tokens: a token provider. */
56
146
  function obtainsTokens(provider) {
57
147
  const candidate = provider;
58
148
  return (typeof candidate.getTokens === 'function' &&
59
149
  typeof candidate.refreshTokens === 'function');
60
150
  }
151
+ /**
152
+ * The client the consumer path hands its factory and tells its strategy: the
153
+ * session's own client, else the key store's, through the same allowlist the
154
+ * strategy is told (`contextClient`) — with `refreshToken` as a key, never a
155
+ * value: no refresh token either client read carried reaches the factory.
156
+ */
157
+ function consumerClient(read) {
158
+ const client = (0, clientAuthentication_1.contextClient)(read.sessionAuth ?? read.keyAuth);
159
+ return client
160
+ ? (0, contractShape_1.asContract)({ ...client, refreshToken: undefined })
161
+ : null;
162
+ }
163
+ /**
164
+ * The consumer path's identity. A factory's: everything it is handed —
165
+ * the client, the seed — and what its binding is made of — the row, the
166
+ * resource. An instance's, which is handed nothing: the destination's
167
+ * identity it was first used for — the row, the resource, the issuer
168
+ * (`uaaUrl`, `oidcIssuerUrl`) and the client id, no secret.
169
+ */
170
+ function consumerIdentity(inputs, instance) {
171
+ const { row, serviceUrl, sapClient, client } = inputs;
172
+ return instance
173
+ ? {
174
+ row,
175
+ serviceUrl,
176
+ sapClient,
177
+ uaaUrl: client?.uaaUrl,
178
+ clientId: client?.uaaClientId,
179
+ oidcIssuerUrl: inputs.means?.oidcIssuerUrl,
180
+ }
181
+ : { row, serviceUrl, sapClient, client, seed: inputs.seed };
182
+ }
183
+ /**
184
+ * The token API's refusal of a destination whose means changed under the
185
+ * consumer's instance: the instance cannot be rebuilt, and the identity of
186
+ * the credential it holds is unknown to the broker.
187
+ */
188
+ function instanceRefusal(destination) {
189
+ return new DestinationConfigError_1.DestinationConfigError(destination, ['provider'], 'the destination’s means changed since the provider instance was first used for it');
190
+ }
61
191
  /** A stored string that counts as present: `''` is none. */
62
192
  function present(value) {
63
193
  return typeof value === 'string' && value !== '';
@@ -69,13 +199,28 @@ function errorCode(error) {
69
199
  }
70
200
  return undefined;
71
201
  }
72
- /** A result carrying a token, or the 3.x error for one that carries none. */
73
- function checked(destination, result) {
202
+ /**
203
+ * A result carrying a token, or the failure for one that carries none:
204
+ * `request-failed`, `token-source`, `no-access-token` — "the token source
205
+ * returned no access_token", minted by auth-errors.
206
+ */
207
+ function checked(result) {
74
208
  if (!result?.authorizationToken) {
75
- throw new Error(`Token provider did not return authorization token for destination "${destination}"`);
209
+ throw new auth_errors_1.AuthProviderFailure(auth_errors_1.authError['request-failed']({
210
+ operation: 'token-source',
211
+ problem: 'no-access-token',
212
+ }));
76
213
  }
77
214
  return result;
78
215
  }
216
+ /** A token write of the provider's persistence: the report's own facts. */
217
+ function persisted(tokens) {
218
+ return {
219
+ credential: tokens.authorizationToken,
220
+ cookies: tokens.tokenType === 'saml',
221
+ expiresAt: tokens.expiresAt,
222
+ };
223
+ }
79
224
  /**
80
225
  * AuthBroker manages authentication tokens for destinations
81
226
  */
@@ -84,28 +229,37 @@ class AuthBroker {
84
229
  serviceKeyStore;
85
230
  sessionStore;
86
231
  provider;
87
- /** A consumer factory's providers: the promise of the build, as `built`. */
88
- consumerBuilt = new Map();
232
+ /** Per (destination, path): the provider handed out while unchanged. */
233
+ resolvedRow = new Map();
234
+ resolvedConsumer = new Map();
89
235
  /**
90
- * One cache per destination, shared by `getProvider` and — when the
91
- * consumer gives no provider — the token API: the promise of the
92
- * build, set before its first read.
236
+ * Per (destination, path): the slot of shared resolutions — every
237
+ * caller of that path is a waiter of its attempt.
93
238
  */
94
- built = new Map();
239
+ rowSlots = new Map();
240
+ consumerSlots = new Map();
241
+ /** The broker-wide slot every `flush()` caller joins. */
242
+ flushSlot = (0, auth_errors_1.sharedAttempt)('persisting-tokens');
243
+ /** Per (destination, path): the last generation a build took. */
244
+ generations = perPath();
245
+ /** Per (destination, path): the newest generation that queued a write. */
246
+ newestWriter = perPath();
95
247
  /**
96
- * The write that failed for a result, keyed on the result the provider
97
- * handed over and returns, then on the destination it was written
98
- * for: the token API throws it to its caller, as 3.x did, while the broker
99
- * keeps retrying. Dropped once a write of that result for that destination
100
- * lands.
248
+ * The destinations whose means changed under the consumer's instance: the
249
+ * token API refuses them until a new broker.
101
250
  */
102
- failedWrites = new WeakMap();
251
+ instanceRefused = new Set();
103
252
  authorization;
104
253
  oidcAuthorization;
105
254
  deviceCodePresenter;
106
255
  samlCookies;
107
256
  assertionReplayStore;
108
- /** getProvider's writes of the session secret, retried on their own. */
257
+ clientAuthentication;
258
+ renewal;
259
+ onWriteFailure;
260
+ /** `config.authDebug === true`: what every token provider it builds is told. */
261
+ authDebug;
262
+ /** Every write of the session secret: one plain queue per destination. */
109
263
  writer;
110
264
  /**
111
265
  * @param config Stores and the provider (instance or factory)
@@ -158,25 +312,15 @@ class AuthBroker {
158
312
  this.deviceCodePresenter = config.deviceCodePresenter;
159
313
  this.samlCookies = config.samlCookies;
160
314
  this.assertionReplayStore = config.assertionReplayStore;
161
- this.logger = logger ?? noOpLogger;
162
- this.writer = new SessionWriter_1.SessionWriter(async (destination, bound) => {
163
- try {
164
- await this.writeSecret(destination, bound);
165
- }
166
- catch (error) {
167
- // Per destination: one result object may be answered for several
168
- // (an instance serves every destination), and a write that lands for
169
- // one says nothing about another's.
170
- let failures = this.failedWrites.get(bound.original);
171
- if (!failures) {
172
- failures = new Map();
173
- this.failedWrites.set(bound.original, failures);
174
- }
175
- failures.set(destination, { error });
176
- throw error;
177
- }
178
- this.failedWrites.get(bound.original)?.delete(destination);
179
- }, this.logger);
315
+ this.clientAuthentication = config.clientAuthentication;
316
+ this.renewal = config.renewal;
317
+ this.onWriteFailure = config.onWriteFailure;
318
+ // Every line the broker writes, and every line of a provider it builds,
319
+ // goes through one guard: a consumer logger that throws or rejects
320
+ // changes no outcome.
321
+ this.logger = (0, quietLogger_1.quietLogger)(logger);
322
+ this.authDebug = config.authDebug === true;
323
+ this.writer = new SessionWriter_1.SessionWriter((destination, write) => this.writeSecret(destination, write), this.logger);
180
324
  this.logger.debug('[AuthBroker] Broker initialized', {
181
325
  hasServiceKeyStore: !!serviceKeyStore,
182
326
  providerForm: provider === undefined
@@ -192,36 +336,83 @@ class AuthBroker {
192
336
  *
193
337
  * Without a consumer `provider`, the provider is the one `getProvider` hands
194
338
  * out for the destination — the same instance, so one token and one renewal
195
- * serve both — and what it obtains is written by its `onTokens`. With one,
339
+ * serve both — and what it obtains is written by its persistence. With one,
196
340
  * the consumer's provider is asked and its answer written, as in 3.x — the
197
341
  * secret alone.
198
342
  *
199
- * Errors from the provider (its typed errors included) propagate unchanged;
200
- * so does a store failure, a failed write of the token included — the token
201
- * stands and the broker keeps retrying the write.
343
+ * A provider's failure is relayed as the same object; a store's read
344
+ * failure reaches the caller as the store raised it. A write that did not
345
+ * land fails the call under `onWriteFailure: 'fail'` (`unknown`,
346
+ * `persisting-tokens`) — the token stands, and the write stays pending: the
347
+ * destination's next call retries it first and is refused while it fails —
348
+ * and is only logged under `'continue'`.
202
349
  *
203
350
  * @throws DestinationConfigError for a destination the key store states as
204
- * `basic` or `snc`, before any provider is asked; without a consumer
205
- * `provider`, also for one whose provider obtains no token.
351
+ * `basic` or `snc`, before any provider is asked; without the
352
+ * `onWriteFailure` option; without a consumer `provider`, also for one
353
+ * whose provider obtains no token.
206
354
  */
207
- async getToken(destination) {
208
- return this.obtain(destination, 'getTokens');
355
+ async getToken(destination, options) {
356
+ return this.obtain(destination, 'getTokens', options?.signal);
209
357
  }
210
358
  /**
211
359
  * A new token for the destination, never the cached one — for a caller whose
212
360
  * token the server has just refused. Calls the provider's `refreshTokens()`;
213
361
  * a renewal already in flight for the destination is joined, not repeated.
214
362
  */
215
- async refreshToken(destination) {
216
- return this.obtain(destination, 'refreshTokens');
363
+ async refreshToken(destination, options) {
364
+ return this.obtain(destination, 'refreshTokens', options?.signal);
217
365
  }
218
- async obtain(destination, method) {
219
- const means = await this.statedForTokens(destination);
366
+ /**
367
+ * The token API's one body. The call's signal releases this caller from
368
+ * every wait it has — the write checks, the means read, the resolution
369
+ * (whose waiter it is, never attaching), its own write — and is
370
+ * passed to the provider's `getTokens` / `refreshTokens`.
371
+ */
372
+ async obtain(destination, method, signal) {
373
+ if (this.provider && !(0, destinations_1.isWriteFailurePolicy)(this.onWriteFailure)) {
374
+ // Every answer of the consumer's provider is written: what a failed
375
+ // write means is the consumer's to say. (Without one, getProvider's
376
+ // token row refuses the same.)
377
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['onWriteFailure'], 'the token API writes the session secret: say what a failed write means');
378
+ }
379
+ await this.settlePending(destination, signal);
380
+ // This call's reads: the means once, for the check and the resolution.
381
+ const reads = this.storeReads(destination);
382
+ const means = await waitFor(() => this.statedForTokens(destination, reads), signal);
220
383
  const result = this.provider
221
- ? await this.obtainFromConsumer(destination, method, means)
222
- : await this.obtainShared(destination, method, means);
384
+ ? await this.obtainFromConsumer(destination, method, reads, signal)
385
+ : await this.obtainShared(destination, method, means, reads, signal);
386
+ // A write queued meanwhile — a discard of the destination's provider the
387
+ // store rejected, say — is caught here.
388
+ await this.settlePending(destination, signal);
389
+ // A signal that aborted after the last wait: never success.
390
+ await stillWanted(signal);
223
391
  return result.authorizationToken;
224
392
  }
393
+ /**
394
+ * Under `onWriteFailure: 'fail'`, a call of the destination is refused while
395
+ * its last write is pending — not yet landed: failed, or still queued
396
+ * or in flight. Asked on entry and right before success: the call awaits
397
+ * every write of the destination queued before this moment, and a failed
398
+ * one that no later write replaced is written once more — the destination's
399
+ * next write, queued like any other. All landed: on entry the call goes on,
400
+ * at the end it returns its success. Still failing: it rejects (`unknown`,
401
+ * `persisting-tokens`). A write queued after the check is not this call's.
402
+ * Under `'continue'` nothing is asked.
403
+ *
404
+ * The one wait is `writer.retry(destination)`'s promise, a waiter of its
405
+ * own slot with the call's signal: an abort releases the caller
406
+ * (`aborted`), never with success, and the write runs on.
407
+ */
408
+ async settlePending(destination, signal) {
409
+ if (this.onWriteFailure !== 'fail')
410
+ return;
411
+ const outcome = await waitFor(() => this.writer.retry(destination), signal);
412
+ if (!outcome.landed) {
413
+ throw new auth_errors_1.AuthProviderFailure((0, auth_errors_1.classify)(outcome.error, 'persisting-tokens'));
414
+ }
415
+ }
225
416
  /**
226
417
  * The destination's means, read first: a destination the key store states
227
418
  * as `basic` or `snc` holds no token, so the token API refuses it before any
@@ -229,10 +420,9 @@ class AuthBroker {
229
420
  * one. A key store that states no `authType`, or no key store, is
230
421
  * a 3.x setup, served as before.
231
422
  */
232
- async statedForTokens(destination) {
233
- const serviceKeyStore = this.serviceKeyStore;
234
- const means = serviceKeyStore
235
- ? await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination))
423
+ async statedForTokens(destination, reads) {
424
+ const means = this.serviceKeyStore
425
+ ? (await reads.read('means'))
236
426
  : null;
237
427
  const authType = means?.authType;
238
428
  if (authType === 'basic' || authType === 'snc') {
@@ -240,8 +430,11 @@ class AuthBroker {
240
430
  }
241
431
  return means;
242
432
  }
243
- /** The token API on `getProvider`'s provider. */
244
- async obtainShared(destination, method, means) {
433
+ /**
434
+ * The token API on the row path's provider — the one `getProvider` hands
435
+ * out, resolved the same way, never through `getProvider` itself.
436
+ */
437
+ async obtainShared(destination, method, means, reads, signal) {
245
438
  if (!this.serviceKeyStore) {
246
439
  throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'serviceKeyStore'], 'the token API needs the provider option, or a service key store stating a token grant');
247
440
  }
@@ -249,129 +442,302 @@ class AuthBroker {
249
442
  means.grantType === 'none') {
250
443
  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');
251
444
  }
252
- const provider = await this.getProvider(destination);
445
+ // The row path's provider, through a waiter of its slot that never
446
+ // attaches: a token call can neither keep a later moment's login
447
+ // alive nor bound it.
448
+ const { provider } = await this.resolveRow(destination, signal, reads);
253
449
  if (!obtainsTokens(provider)) {
254
450
  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');
255
451
  }
256
452
  this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
257
- const result = checked(destination, await provider[method]());
258
- // onTokens wrote it, or failed to: the failure is this caller's too.
259
- this.throwFailedWrite(destination, result);
260
- return result;
453
+ // The provider's failure — a write its persistence awaited included — is
454
+ // relayed as the same object.
455
+ return checked(await provider[method](...tokenOptions(signal)));
261
456
  }
262
457
  /**
263
- * The token API on the consumer's provider, as 3.x: the session read first,
264
- * then the key store, for `serviceUrl` and the client; every answer — a
265
- * cache hit included — written, the secret alone, through the broker's one
266
- * write path.
458
+ * The token API on the consumer's provider: resolved on the consumer path,
459
+ * every answer — a cache hit included — written, the secret alone, through
460
+ * the broker's one write path, with the binding fixed when the provider was
461
+ * taken into use.
267
462
  */
268
- async obtainFromConsumer(destination, method, means) {
269
- const connConfig = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
270
- const serviceUrl = await this.resolveServiceUrl(destination, connConfig);
271
- const { provider, binding } = await this.consumerProviderFor(destination, serviceUrl, connConfig, means);
463
+ async obtainFromConsumer(destination, method, reads, signal) {
464
+ const { provider, binding, generation } = await this.resolveConsumer(destination, reads, signal);
272
465
  this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
273
- const result = checked(destination, await provider[method]());
274
- await this.writer.submit(destination, {
275
- result: { ...result, expiresAt: expiryOf(result) },
276
- original: result,
466
+ const result = checked(await provider[method](...tokenOptions(signal)));
467
+ // The call's own write, queued whatever the caller does next, and awaited
468
+ // raced against its signal: an abort releases the caller
469
+ // (`aborted`, never success) and the write runs on.
470
+ const queued = this.submit('consumer', destination, generation, {
471
+ credential: result.authorizationToken,
472
+ cookies: result.tokenType === 'saml',
473
+ expiresAt: expiryOf(result),
474
+ // The result is authoritative: no refresh token in it writes `''` — the
475
+ // store merges, and the stored one is never carried.
476
+ refreshToken: present(result.refreshToken) ? result.refreshToken : '',
277
477
  binding,
278
- carry: 'any',
279
478
  });
280
- this.throwFailedWrite(destination, result);
479
+ // Handled here too: a wait refused at once (the signal already aborted)
480
+ // never awaits it. It does not reject — the writer never does, and its
481
+ // log lines go through the quiet logger — but nothing here relies on it.
482
+ queued.catch(() => { });
483
+ const outcome = await waitFor(() => queued, signal);
484
+ if (outcome.written && !outcome.landed && this.onWriteFailure === 'fail') {
485
+ throw new auth_errors_1.AuthProviderFailure((0, auth_errors_1.classify)(outcome.error, 'persisting-tokens'));
486
+ }
281
487
  return result;
282
488
  }
283
- /** The failure recorded for this result's write here, thrown as the store raised it. */
284
- throwFailedWrite(destination, result) {
285
- const failed = this.failedWrites.get(result)?.get(destination);
286
- if (failed) {
287
- throw failed.error;
489
+ /**
490
+ * The slot of one (destination, path)'s resolutions: a caller of
491
+ * the path arriving while an attempt is active joins it; when every caller
492
+ * of an attempt has aborted, the attempt leaves the slot at once and the
493
+ * next caller starts afresh. A failed or aborted resolution is not kept.
494
+ */
495
+ slotOf(slots, destination) {
496
+ let slot = slots.get(destination);
497
+ if (slot === undefined) {
498
+ slot = (0, auth_errors_1.sharedAttempt)('token-source');
499
+ slots.set(destination, slot);
288
500
  }
501
+ return slot;
502
+ }
503
+ /** Whether a build of this path was ever committed for the destination. */
504
+ everBuilt(path, destination) {
505
+ return this.generations[path].has(destination);
506
+ }
507
+ /** A committed build's generation, from its path's counter. */
508
+ nextGeneration(path, destination) {
509
+ const generation = (this.generations[path].get(destination) ?? 0) + 1;
510
+ this.generations[path].set(destination, generation);
511
+ return generation;
289
512
  }
290
513
  /**
291
- * The consumer's provider for the destination, cached per destination as a
292
- * promise set before its first read and dropped when it throws, so
293
- * the next call builds again: a factory's build — handed the client resolved
294
- * then — or an instance as given, for every destination, handed no client.
295
- * Either way the binding is fixed here, from the `serviceUrl`, SAP client and
296
- * client of this first call, and kept with the provider.
514
+ * Queues one write of a build of `path`. A write of a build older than the
515
+ * newest build of the same path that has queued a write for the destination
516
+ * is dropped — never written, never pending; a build of the other
517
+ * path never retires this one. A dropped write is not a failed one: the
518
+ * retired provider's holder keeps it, and the destination's session
519
+ * is the newer build's.
297
520
  */
298
- consumerProviderFor(destination, serviceUrl, connConfig, means) {
299
- const cached = this.consumerBuilt.get(destination);
300
- if (cached) {
301
- return cached;
521
+ async submit(path, destination, generation, write) {
522
+ const newest = this.newestWriter[path].get(destination) ?? 0;
523
+ if (generation < newest) {
524
+ this.logger.debug(`[AuthBroker] A replaced provider's session write for ${destination} dropped`);
525
+ return DROPPED;
302
526
  }
303
- const provider = this.provider;
304
- const sapClient = present(connConfig?.sapClient)
305
- ? connConfig.sapClient
306
- : means?.sapClient;
307
- const build = Promise.resolve().then(async () => {
308
- if (typeof provider !== 'function') {
309
- return {
310
- provider,
311
- binding: (0, binding_1.consumerBinding)(serviceUrl, sapClient, null),
312
- };
527
+ this.newestWriter[path].set(destination, generation);
528
+ return Object.freeze({
529
+ written: true,
530
+ ...(await this.writer.submit(destination, write)),
531
+ });
532
+ }
533
+ /** This call's store reads: each source read at most once. */
534
+ storeReads(destination) {
535
+ return new buildIdentity_1.StoreReads((name) => {
536
+ const serviceKeyStore = this.serviceKeyStore;
537
+ switch (name) {
538
+ case 'means':
539
+ return serviceKeyStore
540
+ ? this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination))
541
+ : Promise.resolve(null);
542
+ case 'client':
543
+ return serviceKeyStore
544
+ ? this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination))
545
+ : Promise.resolve(null);
546
+ case 'certificate':
547
+ return this.read(destination, 'client certificate', async () => serviceKeyStore?.getClientCertificate
548
+ ? serviceKeyStore.getClientCertificate(destination)
549
+ : null);
550
+ case 'sessionConnection':
551
+ return this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
552
+ case 'sessionClient':
553
+ return this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
313
554
  }
314
- const client = await this.resolveAuthorizationConfig(destination);
315
- const built = provider(destination, client, {
316
- ...(connConfig ?? {}),
317
- serviceUrl,
318
- });
319
- this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
320
- hasCredentials: !!client,
321
- hasRefreshToken: !!client?.refreshToken,
322
- hasStoredToken: !!(connConfig?.authorizationToken || connConfig?.sessionCookies),
555
+ }, (field) => new DestinationConfigError_1.DestinationConfigError(destination, [field], `the store answered ${field} in a shape the broker cannot take`));
556
+ }
557
+ /**
558
+ * The consumer path's provider for the destination: the cached one
559
+ * while everything it was built from is unchanged; else, for a factory, a
560
+ * new build — the factory called again, its provider starting with nothing
561
+ * — and, for an instance, which cannot be rebuilt, a refusal naming
562
+ * `provider` until a new broker.
563
+ */
564
+ resolveConsumer(destination, reads, signal) {
565
+ const slot = this.slotOf(this.consumerSlots, destination);
566
+ return joined(slot, async (attempt) => {
567
+ const instance = typeof this.provider !== 'function';
568
+ if (this.instanceRefused.has(destination)) {
569
+ throw instanceRefusal(destination);
570
+ }
571
+ const cached = this.resolvedConsumer.get(destination);
572
+ if (cached) {
573
+ if (await cached.identity.unchanged(reads))
574
+ return cached;
575
+ // A doomed attempt changes nothing: every caller left while
576
+ // it re-read, and a fresh attempt may have committed meanwhile —
577
+ // the entry is no longer this attempt's to remove. A live attempt
578
+ // is the slot's only one, so the entry is still the one it read.
579
+ if (attempt.signal.aborted)
580
+ throw attempt.signal.reason;
581
+ this.resolvedConsumer.delete(destination);
582
+ if (instance) {
583
+ this.instanceRefused.add(destination);
584
+ throw instanceRefusal(destination);
585
+ }
586
+ }
587
+ const recorder = new buildIdentity_1.IdentityRecorder(reads);
588
+ const built = await this.buildConsumer(destination, recorder, attempt.signal);
589
+ // The commit, one step, only if the attempt was not aborted: a build
590
+ // every caller left is never cached nor handed out.
591
+ if (attempt.signal.aborted)
592
+ throw attempt.signal.reason;
593
+ const resolved = Object.freeze({
594
+ ...built,
595
+ identity: recorder.seal(),
596
+ generation: this.nextGeneration('consumer', destination),
323
597
  });
598
+ this.resolvedConsumer.set(destination, resolved);
599
+ return resolved;
600
+ }, signal);
601
+ }
602
+ /**
603
+ * Takes the consumer's provider into use for the destination: a factory's
604
+ * build — handed the means and the client, never a stored secret — or an
605
+ * instance as given, handed nothing. The binding is fixed here, from the
606
+ * `serviceUrl` (the session's, else the key store's), the SAP client and
607
+ * the client of this build. Every store answer is read through `recorder`:
608
+ * what it read is the build's identity.
609
+ */
610
+ async buildConsumer(destination, recorder, signal) {
611
+ const provider = this.provider;
612
+ const instance = typeof provider !== 'function';
613
+ // The identity is what this path takes from its stores — never what else
614
+ // the session store holds, which this broker's own writes change.
615
+ const inputs = await this.consumerInputs(destination, recorder.reads);
616
+ recorder.derived(consumerIdentity(inputs, instance), async (reads) => consumerIdentity(await this.consumerInputs(destination, reads), instance));
617
+ const { means, serviceUrl, sapClient, row, client, seed } = inputs;
618
+ if (typeof provider !== 'function') {
324
619
  return {
325
- provider: built,
326
- binding: (0, binding_1.consumerBinding)(serviceUrl, sapClient, client),
620
+ provider,
621
+ binding: (0, binding_1.consumerBinding)(row, serviceUrl, sapClient, null),
327
622
  };
328
- });
329
- this.consumerBuilt.set(destination, build);
330
- build.catch(() => {
331
- if (this.consumerBuilt.get(destination) === build) {
332
- this.consumerBuilt.delete(destination);
623
+ }
624
+ // Never seeded: the factory is handed the means and the client
625
+ // only — no stored token, cookies, expiry or refresh token, in any
626
+ // argument, whatever the stored session's record says. The broker
627
+ // cannot know what the factory composes from what it is handed.
628
+ const strategic = await this.consumerClientAuthentication(destination, means, client, recorder, signal);
629
+ let built;
630
+ let binding;
631
+ if (strategic) {
632
+ // The strategy's answer and the client identity, resolved before the
633
+ // factory; the factory called inside the same guard: a throw is fixed
634
+ // words, nothing of the thrown value, no cause. The binding is only
635
+ // ever written, never compared: this path seeds from nothing.
636
+ binding = (0, binding_1.consumerBinding)(row, serviceUrl, sapClient, strategic.identity);
637
+ const fourth = {
638
+ clientAuthentication: strategic.clientAuthentication,
639
+ ...(strategic.identity
640
+ ? {
641
+ uaaUrl: strategic.identity.uaaUrl,
642
+ clientId: strategic.identity.uaaClientId,
643
+ }
644
+ : {}),
645
+ };
646
+ try {
647
+ built = provider(destination, client, seed, fourth);
648
+ }
649
+ catch {
650
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'clientAuthentication'], 'the token provider factory failed beside the clientAuthentication strategy');
333
651
  }
652
+ }
653
+ else {
654
+ binding = (0, binding_1.consumerBinding)(row, serviceUrl, sapClient, client);
655
+ built = provider(destination, client, seed);
656
+ }
657
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
658
+ hasCredentials: !!client,
334
659
  });
335
- return build;
660
+ return { provider: built, binding };
661
+ }
662
+ /**
663
+ * The token API's strategy path: with a `clientAuthentication` strategy and a
664
+ * destination stating a grant whose client authenticates, the strategy's
665
+ * answer — resolved inside the guard — and the client identity: the secret
666
+ * client, else the certificate client's, read through the context's memoised
667
+ * `readCertificate`. `null` without a strategy (4.0.0: nothing
668
+ * certificate-related is asked) and for a grant that authenticates no
669
+ * client (`saml2_pure`, `none`), as `getProvider` decides.
670
+ *
671
+ * The strategy is told the grant, so a destination that states none is
672
+ * refused before it is called rather than built without it. (An instance
673
+ * given as `provider` is the consumer's own composition: the strategy
674
+ * applies only to what the broker builds, so it is not used there.)
675
+ */
676
+ async consumerClientAuthentication(destination, means, client, recorder, signal) {
677
+ const strategy = this.clientAuthentication;
678
+ if (!strategy) {
679
+ return null;
680
+ }
681
+ // basic and snc never get here: the token API refuses them first
682
+ // (`statedForTokens`).
683
+ if (!means || (means.authType !== 'jwt' && means.authType !== 'saml')) {
684
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['authType', 'grantType'], 'a clientAuthentication strategy is told the grant: the destination must state its authType and grantType');
685
+ }
686
+ const grant = (0, destinations_1.statedGrant)(destination, means.authType, means);
687
+ if (grant === 'saml2_pure' || grant === 'none') {
688
+ return null;
689
+ }
690
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, client, () => recorder.read('certificate'), signal);
691
+ const clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(strategy, context);
692
+ return { clientAuthentication, identity: await (0, clientAuthentication_1.clientIdentity)(context) };
336
693
  }
337
694
  /**
338
- * The credentials a consumer's factory is built with: the session's own when
339
- * it holds them, else the service key's, carrying the refresh token the
340
- * session stored — the 3.x order. A session store of auth-stores 3
695
+ * What a consumer's factory's credentials are composed from, read in the
696
+ * 3.x order: the session's authorization config; only when it has none, the
697
+ * key store's client. The stored session is not read: nothing of it is
698
+ * handed to the factory. A session store of auth-stores 3
341
699
  * answers no client, so the key store's is what is found.
342
700
  */
343
- async resolveAuthorizationConfig(destination) {
344
- const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
701
+ async readAuthorization(reads) {
702
+ const sessionAuth = (await reads.read('sessionClient'));
345
703
  if (sessionAuth) {
346
- return sessionAuth;
704
+ return { sessionAuth, keyAuth: null };
347
705
  }
348
- const session = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
349
- const storedRefreshToken = typeof session?.refreshToken === 'string'
350
- ? session.refreshToken
351
- : undefined;
352
- const serviceKeyStore = this.serviceKeyStore;
353
- const keyAuth = serviceKeyStore
354
- ? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
706
+ const keyAuth = this.serviceKeyStore
707
+ ? (await reads.read('client'))
355
708
  : null;
356
- if (!keyAuth) {
357
- return null;
358
- }
709
+ return { sessionAuth: null, keyAuth };
710
+ }
711
+ /**
712
+ * What the consumer path takes from its stores for one destination, read
713
+ * from `reads` — the same function for a build and for every later call's
714
+ * comparison: the means, the `serviceUrl` (the session's, else the key
715
+ * store's), the SAP client, the row, the client through its allowlist, and
716
+ * the factory's seed of allowlisted connection means.
717
+ */
718
+ async consumerInputs(destination, reads) {
719
+ const means = this.serviceKeyStore
720
+ ? (await reads.read('means'))
721
+ : null;
722
+ const connConfig = (await reads.read('sessionConnection'));
723
+ const serviceUrl = this.serviceUrlOf(destination, connConfig, means);
359
724
  return {
360
- ...keyAuth,
361
- refreshToken: storedRefreshToken ?? keyAuth.refreshToken,
725
+ means,
726
+ serviceUrl,
727
+ sapClient: present(connConfig?.sapClient)
728
+ ? connConfig.sapClient
729
+ : means?.sapClient,
730
+ row: (0, bindingOf_1.consumerRow)(means),
731
+ client: consumerClient(await this.readAuthorization(reads)),
732
+ seed: consumerSeed(connConfig, serviceUrl),
362
733
  };
363
734
  }
364
735
  /**
365
736
  * `serviceUrl` for a consumer's provider, in the 3.x order: the session's,
366
737
  * else the key store's, else an error before the provider is asked.
367
738
  */
368
- async resolveServiceUrl(destination, connConfig) {
369
- let serviceUrl = connConfig?.serviceUrl;
370
- const serviceKeyStore = this.serviceKeyStore;
371
- if (!serviceUrl && serviceKeyStore) {
372
- const keyConn = await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination));
373
- serviceUrl = keyConn?.serviceUrl;
374
- }
739
+ serviceUrlOf(destination, connConfig, means) {
740
+ const serviceUrl = connConfig?.serviceUrl || means?.serviceUrl;
375
741
  if (!serviceUrl) {
376
742
  throw new Error(`Session for destination "${destination}" is missing required field 'serviceUrl'. ` +
377
743
  `SessionStore must contain initial session with serviceUrl${this.serviceKeyStore ? ' or serviceKeyStore must contain serviceUrl' : ''}.`);
@@ -459,39 +825,108 @@ class AuthBroker {
459
825
  * built from the means the service key store holds and the secret the
460
826
  * session store holds.
461
827
  *
462
- * One provider per destination for the broker's life: concurrent first calls
463
- * share one build, and a build that threw is tried again on the next call.
828
+ * A provider is never changed: every call re-reads what the
829
+ * destination's provider was built from — the means, the client, and the
830
+ * certificate client when the build read it — and answers the cached
831
+ * provider while all of it is unchanged. When anything changed, by a single
832
+ * character, it builds a new provider, which starts with nothing — no token,
833
+ * no refresh token, nothing of the old provider or of a session written
834
+ * under other means — and never hands the old one out again; whoever holds
835
+ * it keeps it, and its writes are dropped once the new one has written.
836
+ * Concurrent calls share one resolution; a resolution that threw is tried
837
+ * again on the next call.
464
838
  *
465
839
  * A token provider (the UAA, OIDC and SAML grants) is seeded from the
466
- * session when the stored secret is bound to this destination, and writes
467
- * every token it obtains — at `prepare()`, on expiry, or in `rejected()` after
468
- * a 401 — back to the session store before it answers (see `flush()`).
840
+ * session only at its destination's first build in this broker, and only
841
+ * when the stored secret is bound to exactly this build's means (fully
842
+ * stated); it writes every token it obtains — at `prepare()`, on expiry, or
843
+ * in `rejected()` after a 401 — back to the session store before it answers
844
+ * (see `flush()`).
845
+ *
846
+ * `options.signal` releases this caller from every wait (`aborted`), and is
847
+ * attached to the token or SNC provider answered — built or cached — so a
848
+ * login it starts later is aborted once every session holding it has gone
849
+ *. Without a signal nothing is attached.
469
850
  *
470
851
  * @throws DestinationConfigError when the destination lacks what its type
471
852
  * needs — naming the fields or options, never a value.
472
853
  */
473
- getProvider(destination) {
474
- const cached = this.built.get(destination);
475
- if (cached) {
476
- return cached;
477
- }
478
- // Deferred by one tick, so the promise is in the cache before the build
479
- // reads anything.
480
- const build = Promise.resolve().then(() => this.build(destination));
481
- this.built.set(destination, build);
482
- build.catch(() => {
483
- if (this.built.get(destination) === build) {
484
- this.built.delete(destination);
854
+ async getProvider(destination, options) {
855
+ const signal = options?.signal;
856
+ await this.settlePending(destination, signal);
857
+ const { provider, parties } = await this.resolveRow(destination, signal);
858
+ await this.settlePending(destination, signal);
859
+ // A signal that aborted after the last wait: never success.
860
+ await stillWanted(signal);
861
+ // Attached after the resolution, to the provider answered — built or
862
+ // from the cache — so a login it starts later in a moment is aborted once
863
+ // every session holding it has gone. Nothing kept to detach: a
864
+ // session ends by aborting its signal.
865
+ if (signal !== undefined)
866
+ parties?.attach(signal);
867
+ return provider;
868
+ }
869
+ /**
870
+ * The row path's provider for the destination: the cached one while what
871
+ * its build read is unchanged, else a new build, committed with the
872
+ * next generation of the row path.
873
+ */
874
+ resolveRow(destination, signal, reads = this.storeReads(destination)) {
875
+ const slot = this.slotOf(this.rowSlots, destination);
876
+ return joined(slot, async (attempt) => {
877
+ const cached = this.resolvedRow.get(destination);
878
+ if (cached) {
879
+ if (await cached.identity.unchanged(reads))
880
+ return cached;
881
+ // A doomed attempt changes nothing: every caller left while
882
+ // it re-read, and a fresh attempt may have committed meanwhile —
883
+ // the entry is no longer this attempt's to remove. A live attempt
884
+ // is the slot's only one, so the entry is still the one it read.
885
+ if (attempt.signal.aborted)
886
+ throw attempt.signal.reason;
887
+ // Changed: never handed out again.
888
+ this.resolvedRow.delete(destination);
485
889
  }
486
- });
487
- return build;
890
+ const recorder = new buildIdentity_1.IdentityRecorder(reads);
891
+ const commit = { generation: 0 };
892
+ const { provider, parties } = await this.build(destination, recorder, {
893
+ // Only a first build may start from the store; a replacement starts
894
+ // with nothing.
895
+ seeded: !this.everBuilt('row', destination),
896
+ commit,
897
+ signal: attempt.signal,
898
+ });
899
+ // The commit — the cache set, the generation taken — is one step that
900
+ // runs only if the attempt was not aborted: a build every caller left
901
+ // is never cached, never handed out, and, never asked for a token,
902
+ // writes nothing.
903
+ if (attempt.signal.aborted)
904
+ throw attempt.signal.reason;
905
+ commit.generation = this.nextGeneration('row', destination);
906
+ const resolved = Object.freeze({
907
+ provider,
908
+ parties,
909
+ identity: recorder.seal(),
910
+ generation: commit.generation,
911
+ });
912
+ this.resolvedRow.set(destination, resolved);
913
+ return resolved;
914
+ }, signal);
488
915
  }
489
- async build(destination) {
916
+ /**
917
+ * One build of the destination's row. Every store answer it reads goes
918
+ * through `recorder` — what it read is its identity. `seeded`: whether the
919
+ * build may start from the stored session; `commit`: the generation its
920
+ * writes carry, set when the build is committed; `signal`: the build's
921
+ * attempt, handed to the `clientAuthentication` strategy. Answers
922
+ * the provider and the row it stated.
923
+ */
924
+ async build(destination, recorder, { seeded, commit, signal, }) {
490
925
  const serviceKeyStore = this.serviceKeyStore;
491
926
  if (!serviceKeyStore) {
492
927
  throw new DestinationConfigError_1.DestinationConfigError(destination, ['serviceKeyStore'], 'getProvider reads the means from a service key store, and none was given');
493
928
  }
494
- const means = await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination));
929
+ const means = await recorder.read('means');
495
930
  const authType = (0, destinations_1.statedAuthType)(destination, means);
496
931
  // statedAuthType refuses a destination without means.
497
932
  const stated = means;
@@ -500,7 +935,11 @@ class AuthBroker {
500
935
  provider = (0, destinations_1.basicProvider)(destination, stated);
501
936
  }
502
937
  else if (authType === 'snc') {
503
- provider = (0, destinations_1.sncProvider)(destination, stated, this.logger);
938
+ const snc = (0, destinations_1.sncProvider)(destination, stated, this.logger);
939
+ this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
940
+ authType,
941
+ });
942
+ return { provider: snc, parties: snc };
504
943
  }
505
944
  else {
506
945
  const grant = (0, destinations_1.statedGrant)(destination, authType, stated);
@@ -509,40 +948,100 @@ class AuthBroker {
509
948
  // saml2_pure), what a secret for this destination is bound to
510
949
  // — written with every secret, and required of a stored one
511
950
  // before it seeds — and the stored secret when it is.
512
- const client = grant === 'saml2_pure'
951
+ const secretClient = grant === 'saml2_pure'
513
952
  ? null
514
- : await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination));
515
- const binding = (0, bindingOf_1.destinationBinding)(authType, grant, stated, client);
516
- const stored = grant === 'client_credentials'
953
+ : await recorder.read('client');
954
+ // The consumer's renewal and write policy have no default: a row
955
+ // without either is refused — with everything else it lacks, in one
956
+ // error — before any collaborator, the clientAuthentication strategy
957
+ // included, is called.
958
+ if ((0, destinations_1.optionsLacking)({
959
+ renewal: this.renewal,
960
+ onWriteFailure: this.onWriteFailure,
961
+ }).length > 0) {
962
+ throw this.rowRefusal(destination, grant, stated, secretClient);
963
+ }
964
+ // The consumer's client authentication, resolved inside the guard
965
+ // before any provider exists: a strategy that throws leaves nothing
966
+ // built and nothing cached. Then the client identity the row and the
967
+ // binding take — the secret client, else the certificate client's —
968
+ // so the binding and the seed are chosen from it. Without a strategy
969
+ // nothing of this runs: 4.0.0's client, nothing certificate-related.
970
+ let client = secretClient;
971
+ let clientAuthentication;
972
+ // The certificate client, when the build read it: its certUrl and
973
+ // public certificate go into the binding (never its key).
974
+ let certificateRead;
975
+ if (this.clientAuthentication && grant !== 'saml2_pure') {
976
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, secretClient, () => {
977
+ certificateRead =
978
+ recorder.read('certificate');
979
+ return certificateRead;
980
+ }, signal);
981
+ clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(this.clientAuthentication, context);
982
+ client = await (0, clientAuthentication_1.clientIdentity)(context);
983
+ }
984
+ // A read that failed — and that a consumer's strategy caught — read
985
+ // no certificate client.
986
+ const certificate = certificateRead
987
+ ? await certificateRead.catch(() => null)
988
+ : null;
989
+ // Its certificate is hashed into the binding: one that is no PEM
990
+ // string is refused, never hashed as if the build had read none.
991
+ if (certificate && !present(certificate.certificate)) {
992
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['clientAuthentication'], 'the client certificate the key store answered holds no certificate');
993
+ }
994
+ const computed = (0, bindingOf_1.destinationBinding)(authType, grant, stated, client, certificate);
995
+ // The strategy path: a resource neither side states matches.
996
+ const binding = clientAuthentication
997
+ ? (0, binding_1.strategyBinding)(computed)
998
+ : computed;
999
+ // Only a first build reads the session: a replacement for changed
1000
+ // means starts with nothing. client_credentials takes no seed.
1001
+ const stored = grant === 'client_credentials' || !seeded
517
1002
  ? null
518
1003
  : await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
519
1004
  const secret = this.boundOrDiscarded(destination, stored, binding);
520
- // The expiry is fixed when the result arrives, not when a retry
521
- // finally writes it — on a copy; the outcome of the write is recorded
522
- // against the result itself, which the provider also returns to the
523
- // token API.
524
- const onTokens = (result) => this.writer.submit(destination, {
525
- result: { ...result, expiresAt: expiryOf(result) },
526
- original: result,
527
- binding,
528
- carry: 'bound',
529
- });
1005
+ // The refresh token this build owns: the one the builder handed its
1006
+ // provider from the checked session (`RowBuild`), then whatever each
1007
+ // of its writes made of it — never one read from the store at write
1008
+ // time. Set before the provider can write.
1009
+ let owned;
1010
+ // The provider's persistence writes through here: resolved when the
1011
+ // write landed, rejected with the store's error when it did not —
1012
+ // refreshStatePersistence then does what onWriteFailure says.
1013
+ const write = async (tokens) => {
1014
+ const next = ownedAfter(owned, tokens.refreshToken);
1015
+ owned = next.owned;
1016
+ const outcome = await this.submit('row', destination, commit.generation, {
1017
+ ...persisted(tokens),
1018
+ refreshToken: next.written,
1019
+ binding,
1020
+ });
1021
+ if (outcome.written && !outcome.landed)
1022
+ throw outcome.error;
1023
+ };
530
1024
  const common = {
531
1025
  destination,
532
1026
  client,
533
1027
  secret,
1028
+ ...(clientAuthentication ? { clientAuthentication } : {}),
1029
+ renewal: this.renewal,
1030
+ onWriteFailure: this.onWriteFailure,
1031
+ write,
534
1032
  logger: this.logger,
535
- onTokens,
1033
+ authDebug: this.authDebug,
536
1034
  };
1035
+ let built;
537
1036
  if ((0, destinations_1.isUaaGrant)(grant)) {
538
- provider = (0, destinations_1.uaaProvider)({
1037
+ built = (0, destinations_1.uaaProvider)({
539
1038
  ...common,
540
1039
  grant,
541
1040
  authorization: this.authorization,
542
1041
  });
543
1042
  }
544
1043
  else if ((0, destinations_1.isOidcGrant)(grant)) {
545
- provider = (0, destinations_1.oidcProvider)({
1044
+ built = (0, destinations_1.oidcProvider)({
546
1045
  ...common,
547
1046
  grant,
548
1047
  means: stated,
@@ -551,7 +1050,7 @@ class AuthBroker {
551
1050
  });
552
1051
  }
553
1052
  else if ((0, destinations_1.isSamlGrant)(grant)) {
554
- provider = (0, destinations_1.samlProvider)({
1053
+ built = (0, destinations_1.samlProvider)({
555
1054
  ...common,
556
1055
  grant,
557
1056
  means: stated,
@@ -564,29 +1063,77 @@ class AuthBroker {
564
1063
  // statedGrant admits only the allowed `authType` / `grantType` pairs.
565
1064
  throw new Error(`unreachable grant ${grant}`);
566
1065
  }
1066
+ owned = built.seededRefreshToken;
1067
+ const tokenProvider = built.provider;
567
1068
  this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
568
1069
  authType,
569
1070
  grant,
570
1071
  seeded: !!secret,
571
1072
  });
572
- return provider;
1073
+ return { provider: tokenProvider, parties: tokenProvider };
573
1074
  }
574
1075
  const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
575
1076
  const client = authType === 'jwt'
576
- ? await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination))
1077
+ ? await recorder.read('client')
577
1078
  : null;
578
1079
  provider = (0, destinations_1.handedOverProvider)(destination, authType, secret, (0, bindingOf_1.destinationBinding)(authType, 'none', stated, client));
579
1080
  }
580
1081
  this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
581
1082
  authType,
582
1083
  });
583
- return provider;
1084
+ return { provider, parties: undefined };
1085
+ }
1086
+ /**
1087
+ * A token row's refusal when the consumer's `renewal` or `onWriteFailure`
1088
+ * is missing — named beside every other field and option the row lacks, in
1089
+ * one error — decided before any collaborator is called. With a
1090
+ * clientAuthentication strategy and no secret client, the client's fields
1091
+ * are not judged: the strategy would tell the certificate client's.
1092
+ */
1093
+ rowRefusal(destination, grant, means, secretClient) {
1094
+ const clientAuthenticated = !!this.clientAuthentication && grant !== 'saml2_pure';
1095
+ const client = clientAuthenticated && !secretClient ? undefined : secretClient;
1096
+ const check = {
1097
+ destination,
1098
+ client,
1099
+ clientAuthenticated,
1100
+ renewal: this.renewal,
1101
+ onWriteFailure: this.onWriteFailure,
1102
+ };
1103
+ const refusal = (0, destinations_1.isUaaGrant)(grant)
1104
+ ? (0, destinations_1.uaaRefusal)({ ...check, grant, authorization: this.authorization })
1105
+ : (0, destinations_1.isOidcGrant)(grant)
1106
+ ? (0, destinations_1.oidcRefusal)({
1107
+ ...check,
1108
+ grant,
1109
+ means,
1110
+ oidcAuthorization: this.oidcAuthorization,
1111
+ deviceCodePresenter: this.deviceCodePresenter,
1112
+ })
1113
+ : (0, destinations_1.samlRefusal)({
1114
+ ...check,
1115
+ grant,
1116
+ means,
1117
+ authorization: this.authorization,
1118
+ samlCookies: this.samlCookies,
1119
+ assertionReplayStore: this.assertionReplayStore,
1120
+ });
1121
+ return (refusal ??
1122
+ new DestinationConfigError_1.DestinationConfigError(destination, (0, destinations_1.optionsLacking)(check), `a destination with grantType ${grant} lacks what its grant needs`));
584
1123
  }
585
1124
  /**
586
1125
  * The stored secret when it is bound to this destination's resource and
587
1126
  * issuer, else `null`: the provider is then built as with no session
588
- * and logs in afresh — the refresh token is not spent either. The log line
589
- * names the destination only: never a URI, never a token.
1127
+ * and logs in afresh — the refresh token is not spent either.
1128
+ *
1129
+ * A stored credential not taken is logged in fixed words naming the
1130
+ * destination only — never a URI, a record or a token: a `warn` when the
1131
+ * binding could ever be seeded (`couldBeSeeded`) — the stored secret is not
1132
+ * recorded under the current means: another resource, row, client,
1133
+ * endpoint or trust, a 4.x record, or none — and a `debug` line when it
1134
+ * never could — a row never seeded by design (`token_exchange`), means
1135
+ * lacking what the record needs, or no `serviceUrl` — since that holds on
1136
+ * every start and nothing was discarded by a change.
590
1137
  */
591
1138
  boundOrDiscarded(destination, stored, binding) {
592
1139
  if (!stored || (0, binding_1.boundHere)(stored, binding))
@@ -594,44 +1141,55 @@ class AuthBroker {
594
1141
  if (present(stored.authorizationToken) ||
595
1142
  present(stored.sessionCookies) ||
596
1143
  present(stored.refreshToken)) {
597
- this.logger.warn(`[AuthBroker] ${destination}: secret bound to another resource, discarded`);
1144
+ if ((0, binding_1.couldBeSeeded)(binding)) {
1145
+ this.logger.warn(`[AuthBroker] ${destination}: the stored session secret is not recorded as issued under the destination's current means; not used, the provider obtains a new one`);
1146
+ }
1147
+ else {
1148
+ this.logger.debug(`[AuthBroker] ${destination}: the destination's means do not state everything a session secret is bound to; a stored one is never used`);
1149
+ }
598
1150
  }
599
1151
  return null;
600
1152
  }
601
1153
  /**
602
- * Waits for the session writes still pending — each gets one more attempt —
603
- * and rejects naming the destinations whose store still refuses. Call it on
604
- * shutdown to know whether every token a provider obtained is stored; the
605
- * broker keeps retrying after a rejection.
1154
+ * Waits for every session write queued so far and gives each pending one —
1155
+ * a write that did not land — one more attempt; rejects naming the
1156
+ * destinations whose store still refuses (an `AggregateError` of
1157
+ * `SessionWriteFailure`s). Nothing retries a pending write on its own: only
1158
+ * the destination's next write or `flush()` does, so call it on shutdown to
1159
+ * know whether every token a provider obtained is stored.
1160
+ *
1161
+ * Every caller joins one broker-wide slot: its signal releases that
1162
+ * caller alone (`aborted`); the attempts run on, and a write still failing
1163
+ * stays pending. The `AggregateError` is rethrown as the same object to
1164
+ * every caller.
606
1165
  */
607
- flush() {
608
- return this.writer.flush();
1166
+ flush(options) {
1167
+ return joined(this.flushSlot, () => this.writer.flush(), options?.signal);
609
1168
  }
610
1169
  /**
611
- * One write of the destination's session secret — and nothing else:
612
- * `{ authorizationToken, expiresAt, refreshToken, issuedFor, issuedBy }` in
613
- * one `saveSession` — or, for a `saml2_pure` result (`tokenType: 'saml'`),
614
- * `{ sessionCookies, expiresAt, issuedFor, issuedBy }`: cookies are written
615
- * as cookies, every other result as a token (`saml2_bearer` included). No means is ever written: not `serviceUrl`, not
616
- * `authType`, not the client — they live in the key store, which the broker
617
- * never writes.
1170
+ * One write of the destination's session secret — and nothing else — in one
1171
+ * `saveSession`, built from the write alone: the store merges, so every
1172
+ * field that must not survive from an earlier write is stated.
618
1173
  *
619
- * - `expiresAt`: fixed by `onTokens` when the result arrived — the result's
620
- * own, else the `expiresIn` it reports counted from then (the rule the
621
- * provider applies to its own cache).
622
- * - `refreshToken`: the result's, else the one the session holds, read at
623
- * write time, so a result without one does not erase the stored one — for
624
- * a provider the broker built, only a stored one bound where this one is:
625
- * a refresh token obtained for another resource or from another client is
626
- * not carried into this secret. A consumer's provider was seeded
627
- * with whatever the session held, so whatever it holds is carried.
628
- * - `issuedFor` / `issuedBy`: the binding computed when the provider was
629
- * built, each left out when the means lack its source — so the
630
- * store clears it.
1174
+ * - A credential: `{ authorizationToken, expiresAt, refreshToken, issuedFor,
1175
+ * issuedBy }`, or for `saml2_pure`'s cookies (`tokenType: 'saml'`)
1176
+ * `{ sessionCookies, expiresAt, refreshToken: '', issuedFor, issuedBy }` —
1177
+ * SAML has no refresh token, and one stored beside earlier cookies or a
1178
+ * token is not this credential's.
1179
+ * - `refreshToken`: decided when the write was submitted — the build's own,
1180
+ * or `''`, the store's clearing operation. The store is never read here.
1181
+ * - `issuedFor` / `issuedBy`: the binding fixed when the provider was built,
1182
+ * `issuedFor` written as `''` when the means lack its source — never left
1183
+ * out, so no earlier binding survives beside a new credential.
1184
+ * - A write that holds no credential (a refresh token discarded before any
1185
+ * was held) writes only `refreshToken: ''`: the stored credential keeps
1186
+ * its own binding — never re-labelled — and loses its refresh token.
1187
+ * - `expiresAt`: fixed when the credential arrived.
631
1188
  * - A destination the key store now states as `basic` or `snc` is not
632
1189
  * written: those obtain no session secret.
633
1190
  */
634
- async writeSecret(destination, { result, binding, carry }) {
1191
+ async writeSecret(destination, write) {
1192
+ const { binding } = write;
635
1193
  const serviceKeyStore = this.serviceKeyStore;
636
1194
  const means = serviceKeyStore
637
1195
  ? await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination))
@@ -640,49 +1198,39 @@ class AuthBroker {
640
1198
  this.logger.warn(`[AuthBroker] Not written: ${destination} is a ${means.authType} destination, which holds no session secret`);
641
1199
  return;
642
1200
  }
643
- let secret;
644
- if (result.tokenType === 'saml') {
645
- // saml2_pure: the cookies are the credential, and SAML has no refresh
646
- // token to carry.
647
- secret = {
648
- sessionCookies: result.authorizationToken,
649
- expiresAt: result.expiresAt,
650
- };
651
- }
652
- else {
653
- const stored = result.refreshToken
654
- ? null
655
- : await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
656
- const storedRefreshToken = present(stored?.refreshToken) &&
657
- (carry === 'any' || (0, binding_1.boundHere)(stored, binding))
658
- ? stored.refreshToken
659
- : undefined;
660
- secret = {
661
- authorizationToken: result.authorizationToken,
662
- expiresAt: result.expiresAt,
663
- refreshToken: result.refreshToken || storedRefreshToken,
664
- };
1201
+ if (write.credential === '') {
1202
+ await this.sessionStore.saveSession(destination, (0, contractShape_1.asContract)({ refreshToken: '' }));
1203
+ this.logger.info(`[AuthBroker] Refresh token cleared for ${destination}`);
1204
+ return;
665
1205
  }
666
- if (binding.issuedFor !== undefined)
667
- secret.issuedFor = binding.issuedFor;
668
- if (binding.issuedBy !== undefined)
669
- secret.issuedBy = binding.issuedBy;
1206
+ const secret = (0, contractShape_1.asContract)({
1207
+ ...(write.cookies
1208
+ ? { sessionCookies: write.credential }
1209
+ : { authorizationToken: write.credential }),
1210
+ expiresAt: write.expiresAt,
1211
+ // saml2_pure: the cookies are the credential, and SAML has none.
1212
+ refreshToken: write.cookies ? '' : write.refreshToken,
1213
+ issuedFor: binding.issuedFor ?? '',
1214
+ issuedBy: binding.issuedBy,
1215
+ });
670
1216
  await this.sessionStore.saveSession(destination, secret);
671
1217
  this.logger.info(`[AuthBroker] Session secret saved for ${destination}`, {
672
- credential: secret.sessionCookies !== undefined ? 'cookies' : 'token',
673
- hasRefreshToken: !!secret.refreshToken,
674
- expiresAt: result.expiresAt,
1218
+ credential: write.cookies ? 'cookies' : 'token',
1219
+ hasRefreshToken: present(secret.refreshToken),
1220
+ expiresAt: write.expiresAt,
675
1221
  });
676
1222
  }
677
1223
  /**
678
1224
  * An `ITokenRefresher` for one destination, for injection into a connection:
679
1225
  * `getToken()` is the broker's `getToken`, `refreshToken()` its forced
680
- * `refreshToken`.
1226
+ * `refreshToken` — each call a waiter with `options.signal`, so a
1227
+ * refresher held by a session ends with it.
681
1228
  */
682
- createTokenRefresher(destination) {
1229
+ createTokenRefresher(destination, options) {
1230
+ const signal = options?.signal;
683
1231
  return {
684
- getToken: () => this.getToken(destination),
685
- refreshToken: () => this.refreshToken(destination),
1232
+ getToken: () => this.getToken(destination, { signal }),
1233
+ refreshToken: () => this.refreshToken(destination, { signal }),
686
1234
  };
687
1235
  }
688
1236
  }