@mcp-abap-adt/auth-broker 4.1.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,19 +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");
27
36
  const clientAuthentication_1 = require("./clientAuthentication");
37
+ const contractShape_1 = require("./contractShape");
28
38
  const DestinationConfigError_1 = require("./DestinationConfigError");
29
39
  const destinations_1 = require("./destinations");
40
+ const quietLogger_1 = require("./quietLogger");
30
41
  const SessionWriter_1 = require("./SessionWriter");
31
- const noOpLogger = {
32
- info: () => { },
33
- error: () => { },
34
- warn: () => { },
35
- debug: () => { },
36
- };
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
+ }
37
83
  /** The session secret's fields on a connection config — never means. */
38
84
  /** The session's side: the secret and what it is bound to. */
39
85
  const SECRET_FIELDS = [
@@ -57,19 +103,15 @@ const SEED_MEANS = [
57
103
  'grantType',
58
104
  ];
59
105
  /**
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.
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.
65
110
  */
66
- function strategySeed(connConfig, binding, serviceUrl) {
111
+ function consumerSeed(connConfig, serviceUrl) {
67
112
  const source = (connConfig ?? {});
68
- const fields = connConfig && (0, binding_1.boundHere)(connConfig, binding)
69
- ? [...SEED_MEANS, ...SECRET_FIELDS]
70
- : SEED_MEANS;
71
113
  const seed = {};
72
- for (const field of fields) {
114
+ for (const field of SEED_MEANS) {
73
115
  if (source[field] !== undefined)
74
116
  seed[field] = source[field];
75
117
  }
@@ -84,44 +126,67 @@ function expiryOf(result) {
84
126
  }
85
127
  return undefined;
86
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
+ }
87
145
  /** A provider `getProvider` built that obtains tokens: a token provider. */
88
146
  function obtainsTokens(provider) {
89
147
  const candidate = provider;
90
148
  return (typeof candidate.getTokens === 'function' &&
91
149
  typeof candidate.refreshTokens === 'function');
92
150
  }
93
- /** The refresh token a stored session holds, unchecked. */
94
- function storedRefreshTokenOf(session) {
95
- return typeof session?.refreshToken === 'string'
96
- ? session.refreshToken
97
- : undefined;
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;
98
162
  }
99
163
  /**
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`.)
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.
103
169
  */
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
- };
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 };
113
182
  }
114
183
  /**
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.
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.
119
187
  */
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;
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');
125
190
  }
126
191
  /** A stored string that counts as present: `''` is none. */
127
192
  function present(value) {
@@ -134,13 +199,28 @@ function errorCode(error) {
134
199
  }
135
200
  return undefined;
136
201
  }
137
- /** A result carrying a token, or the 3.x error for one that carries none. */
138
- 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) {
139
208
  if (!result?.authorizationToken) {
140
- 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
+ }));
141
213
  }
142
214
  return result;
143
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
+ }
144
224
  /**
145
225
  * AuthBroker manages authentication tokens for destinations
146
226
  */
@@ -149,29 +229,37 @@ class AuthBroker {
149
229
  serviceKeyStore;
150
230
  sessionStore;
151
231
  provider;
152
- /** A consumer factory's providers: the promise of the build, as `built`. */
153
- consumerBuilt = new Map();
232
+ /** Per (destination, path): the provider handed out while unchanged. */
233
+ resolvedRow = new Map();
234
+ resolvedConsumer = new Map();
154
235
  /**
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.
236
+ * Per (destination, path): the slot of shared resolutions — every
237
+ * caller of that path is a waiter of its attempt.
158
238
  */
159
- 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();
160
247
  /**
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.
248
+ * The destinations whose means changed under the consumer's instance: the
249
+ * token API refuses them until a new broker.
166
250
  */
167
- failedWrites = new WeakMap();
251
+ instanceRefused = new Set();
168
252
  authorization;
169
253
  oidcAuthorization;
170
254
  deviceCodePresenter;
171
255
  samlCookies;
172
256
  assertionReplayStore;
173
257
  clientAuthentication;
174
- /** getProvider's writes of the session secret, retried on their own. */
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. */
175
263
  writer;
176
264
  /**
177
265
  * @param config Stores and the provider (instance or factory)
@@ -225,25 +313,14 @@ class AuthBroker {
225
313
  this.samlCookies = config.samlCookies;
226
314
  this.assertionReplayStore = config.assertionReplayStore;
227
315
  this.clientAuthentication = config.clientAuthentication;
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);
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);
247
324
  this.logger.debug('[AuthBroker] Broker initialized', {
248
325
  hasServiceKeyStore: !!serviceKeyStore,
249
326
  providerForm: provider === undefined
@@ -259,36 +336,83 @@ class AuthBroker {
259
336
  *
260
337
  * Without a consumer `provider`, the provider is the one `getProvider` hands
261
338
  * 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,
339
+ * serve both — and what it obtains is written by its persistence. With one,
263
340
  * the consumer's provider is asked and its answer written, as in 3.x — the
264
341
  * secret alone.
265
342
  *
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.
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'`.
269
349
  *
270
350
  * @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.
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.
273
354
  */
274
- async getToken(destination) {
275
- return this.obtain(destination, 'getTokens');
355
+ async getToken(destination, options) {
356
+ return this.obtain(destination, 'getTokens', options?.signal);
276
357
  }
277
358
  /**
278
359
  * A new token for the destination, never the cached one — for a caller whose
279
360
  * token the server has just refused. Calls the provider's `refreshTokens()`;
280
361
  * a renewal already in flight for the destination is joined, not repeated.
281
362
  */
282
- async refreshToken(destination) {
283
- return this.obtain(destination, 'refreshTokens');
363
+ async refreshToken(destination, options) {
364
+ return this.obtain(destination, 'refreshTokens', options?.signal);
284
365
  }
285
- async obtain(destination, method) {
286
- 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);
287
383
  const result = this.provider
288
- ? await this.obtainFromConsumer(destination, method, means)
289
- : 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);
290
391
  return result.authorizationToken;
291
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
+ }
292
416
  /**
293
417
  * The destination's means, read first: a destination the key store states
294
418
  * as `basic` or `snc` holds no token, so the token API refuses it before any
@@ -296,10 +420,9 @@ class AuthBroker {
296
420
  * one. A key store that states no `authType`, or no key store, is
297
421
  * a 3.x setup, served as before.
298
422
  */
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))
423
+ async statedForTokens(destination, reads) {
424
+ const means = this.serviceKeyStore
425
+ ? (await reads.read('means'))
303
426
  : null;
304
427
  const authType = means?.authType;
305
428
  if (authType === 'basic' || authType === 'snc') {
@@ -307,8 +430,11 @@ class AuthBroker {
307
430
  }
308
431
  return means;
309
432
  }
310
- /** The token API on `getProvider`'s provider. */
311
- 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) {
312
438
  if (!this.serviceKeyStore) {
313
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');
314
440
  }
@@ -316,144 +442,222 @@ class AuthBroker {
316
442
  means.grantType === 'none') {
317
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');
318
444
  }
319
- 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);
320
449
  if (!obtainsTokens(provider)) {
321
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');
322
451
  }
323
452
  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;
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)));
328
456
  }
329
457
  /**
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.
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.
334
462
  */
335
- async obtainFromConsumer(destination, method, means) {
336
- const connConfig = await this.read(destination, 'session connection config', () => this.sessionStore.getConnectionConfig(destination));
337
- const serviceUrl = await this.resolveServiceUrl(destination, connConfig);
338
- const { provider, binding, carry } = 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);
339
465
  this.logger.debug(`[AuthBroker] ${method} for ${destination}`);
340
- const result = checked(destination, await provider[method]());
341
- await this.writer.submit(destination, {
342
- result: { ...result, expiresAt: expiryOf(result) },
343
- 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 : '',
344
477
  binding,
345
- carry,
346
478
  });
347
- 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
+ }
348
487
  return result;
349
488
  }
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;
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);
355
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;
356
512
  }
357
513
  /**
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.
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.
364
520
  */
365
- consumerProviderFor(destination, serviceUrl, connConfig, means) {
366
- const cached = this.consumerBuilt.get(destination);
367
- if (cached) {
368
- 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;
369
526
  }
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
- };
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));
381
554
  }
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
- }
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);
432
570
  }
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
- });
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
+ }
442
586
  }
443
- this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
444
- hasCredentials: !!client,
445
- hasRefreshToken: !!client?.refreshToken,
446
- hasStoredToken: !!(connConfig?.authorizationToken || connConfig?.sessionCookies),
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),
447
597
  });
448
- return { provider: built, binding, carry };
449
- });
450
- this.consumerBuilt.set(destination, build);
451
- build.catch(() => {
452
- if (this.consumerBuilt.get(destination) === build) {
453
- this.consumerBuilt.delete(destination);
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') {
619
+ return {
620
+ provider,
621
+ binding: (0, binding_1.consumerBinding)(row, serviceUrl, sapClient, null),
622
+ };
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);
454
648
  }
649
+ catch {
650
+ throw new DestinationConfigError_1.DestinationConfigError(destination, ['provider', 'clientAuthentication'], 'the token provider factory failed beside the clientAuthentication strategy');
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,
455
659
  });
456
- return build;
660
+ return { provider: built, binding };
457
661
  }
458
662
  /**
459
663
  * The token API's strategy path: with a `clientAuthentication` strategy and a
@@ -469,7 +673,7 @@ class AuthBroker {
469
673
  * given as `provider` is the consumer's own composition: the strategy
470
674
  * applies only to what the broker builds, so it is not used there.)
471
675
  */
472
- async consumerClientAuthentication(destination, means, client) {
676
+ async consumerClientAuthentication(destination, means, client, recorder, signal) {
473
677
  const strategy = this.clientAuthentication;
474
678
  if (!strategy) {
475
679
  return null;
@@ -483,46 +687,57 @@ class AuthBroker {
483
687
  if (grant === 'saml2_pure' || grant === 'none') {
484
688
  return null;
485
689
  }
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));
690
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, client, () => recorder.read('certificate'), signal);
490
691
  const clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(strategy, context);
491
692
  return { clientAuthentication, identity: await (0, clientAuthentication_1.clientIdentity)(context) };
492
693
  }
493
694
  /**
494
695
  * What a consumer's factory's credentials are composed from, read in the
495
696
  * 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
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
497
699
  * answers no client, so the key store's is what is found.
498
700
  */
499
- async readAuthorization(destination) {
500
- const sessionAuth = await this.read(destination, 'session authorization config', () => this.sessionStore.getAuthorizationConfig(destination));
701
+ async readAuthorization(reads) {
702
+ const sessionAuth = (await reads.read('sessionClient'));
501
703
  if (sessionAuth) {
502
- return { sessionAuth, keyAuth: null, session: null, sessionRead: false };
704
+ return { sessionAuth, keyAuth: null };
503
705
  }
504
- const session = await this.loadStoredSession(destination);
505
- const serviceKeyStore = this.serviceKeyStore;
506
- const keyAuth = serviceKeyStore
507
- ? await this.read(destination, 'service key authorization config', () => serviceKeyStore.getAuthorizationConfig(destination))
706
+ const keyAuth = this.serviceKeyStore
707
+ ? (await reads.read('client'))
508
708
  : null;
509
- return { sessionAuth: null, keyAuth, session, sessionRead: true };
709
+ return { sessionAuth: null, keyAuth };
510
710
  }
511
- /** The session the store holds for the destination, or null. */
512
- loadStoredSession(destination) {
513
- return this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
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);
724
+ return {
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),
733
+ };
514
734
  }
515
735
  /**
516
736
  * `serviceUrl` for a consumer's provider, in the 3.x order: the session's,
517
737
  * else the key store's, else an error before the provider is asked.
518
738
  */
519
- async resolveServiceUrl(destination, connConfig) {
520
- let serviceUrl = connConfig?.serviceUrl;
521
- const serviceKeyStore = this.serviceKeyStore;
522
- if (!serviceUrl && serviceKeyStore) {
523
- const keyConn = await this.read(destination, 'service key connection config', () => serviceKeyStore.getConnectionConfig(destination));
524
- serviceUrl = keyConn?.serviceUrl;
525
- }
739
+ serviceUrlOf(destination, connConfig, means) {
740
+ const serviceUrl = connConfig?.serviceUrl || means?.serviceUrl;
526
741
  if (!serviceUrl) {
527
742
  throw new Error(`Session for destination "${destination}" is missing required field 'serviceUrl'. ` +
528
743
  `SessionStore must contain initial session with serviceUrl${this.serviceKeyStore ? ' or serviceKeyStore must contain serviceUrl' : ''}.`);
@@ -610,39 +825,108 @@ class AuthBroker {
610
825
  * built from the means the service key store holds and the secret the
611
826
  * session store holds.
612
827
  *
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.
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.
615
838
  *
616
839
  * 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()`).
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.
620
850
  *
621
851
  * @throws DestinationConfigError when the destination lacks what its type
622
852
  * needs — naming the fields or options, never a value.
623
853
  */
624
- getProvider(destination) {
625
- const cached = this.built.get(destination);
626
- if (cached) {
627
- return cached;
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);
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);
636
889
  }
637
- });
638
- 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);
639
915
  }
640
- 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, }) {
641
925
  const serviceKeyStore = this.serviceKeyStore;
642
926
  if (!serviceKeyStore) {
643
927
  throw new DestinationConfigError_1.DestinationConfigError(destination, ['serviceKeyStore'], 'getProvider reads the means from a service key store, and none was given');
644
928
  }
645
- const means = await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination));
929
+ const means = await recorder.read('means');
646
930
  const authType = (0, destinations_1.statedAuthType)(destination, means);
647
931
  // statedAuthType refuses a destination without means.
648
932
  const stated = means;
@@ -651,7 +935,11 @@ class AuthBroker {
651
935
  provider = (0, destinations_1.basicProvider)(destination, stated);
652
936
  }
653
937
  else if (authType === 'snc') {
654
- 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 };
655
943
  }
656
944
  else {
657
945
  const grant = (0, destinations_1.statedGrant)(destination, authType, stated);
@@ -662,7 +950,17 @@ class AuthBroker {
662
950
  // before it seeds — and the stored secret when it is.
663
951
  const secretClient = grant === 'saml2_pure'
664
952
  ? null
665
- : await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination));
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
+ }
666
964
  // The consumer's client authentication, resolved inside the guard
667
965
  // before any provider exists: a strategy that throws leaves nothing
668
966
  // built and nothing cached. Then the client identity the row and the
@@ -671,49 +969,79 @@ class AuthBroker {
671
969
  // nothing of this runs: 4.0.0's client, nothing certificate-related.
672
970
  let client = secretClient;
673
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;
674
975
  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));
976
+ const context = (0, clientAuthentication_1.clientAuthenticationContext)(destination, grant, secretClient, () => {
977
+ certificateRead =
978
+ recorder.read('certificate');
979
+ return certificateRead;
980
+ }, signal);
678
981
  clientAuthentication = await (0, clientAuthentication_1.resolveClientAuthentication)(this.clientAuthentication, context);
679
982
  client = await (0, clientAuthentication_1.clientIdentity)(context);
680
983
  }
681
- const computed = (0, bindingOf_1.destinationBinding)(authType, grant, stated, client);
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);
682
995
  // The strategy path: a resource neither side states matches.
683
996
  const binding = clientAuthentication
684
997
  ? (0, binding_1.strategyBinding)(computed)
685
998
  : computed;
686
- const stored = grant === 'client_credentials'
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
687
1002
  ? null
688
1003
  : await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
689
1004
  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
- });
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
+ };
700
1024
  const common = {
701
1025
  destination,
702
1026
  client,
703
1027
  secret,
704
1028
  ...(clientAuthentication ? { clientAuthentication } : {}),
1029
+ renewal: this.renewal,
1030
+ onWriteFailure: this.onWriteFailure,
1031
+ write,
705
1032
  logger: this.logger,
706
- onTokens,
1033
+ authDebug: this.authDebug,
707
1034
  };
1035
+ let built;
708
1036
  if ((0, destinations_1.isUaaGrant)(grant)) {
709
- provider = (0, destinations_1.uaaProvider)({
1037
+ built = (0, destinations_1.uaaProvider)({
710
1038
  ...common,
711
1039
  grant,
712
1040
  authorization: this.authorization,
713
1041
  });
714
1042
  }
715
1043
  else if ((0, destinations_1.isOidcGrant)(grant)) {
716
- provider = (0, destinations_1.oidcProvider)({
1044
+ built = (0, destinations_1.oidcProvider)({
717
1045
  ...common,
718
1046
  grant,
719
1047
  means: stated,
@@ -722,7 +1050,7 @@ class AuthBroker {
722
1050
  });
723
1051
  }
724
1052
  else if ((0, destinations_1.isSamlGrant)(grant)) {
725
- provider = (0, destinations_1.samlProvider)({
1053
+ built = (0, destinations_1.samlProvider)({
726
1054
  ...common,
727
1055
  grant,
728
1056
  means: stated,
@@ -735,29 +1063,77 @@ class AuthBroker {
735
1063
  // statedGrant admits only the allowed `authType` / `grantType` pairs.
736
1064
  throw new Error(`unreachable grant ${grant}`);
737
1065
  }
1066
+ owned = built.seededRefreshToken;
1067
+ const tokenProvider = built.provider;
738
1068
  this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
739
1069
  authType,
740
1070
  grant,
741
1071
  seeded: !!secret,
742
1072
  });
743
- return provider;
1073
+ return { provider: tokenProvider, parties: tokenProvider };
744
1074
  }
745
1075
  const secret = await this.read(destination, 'session', () => this.sessionStore.loadSession(destination));
746
1076
  const client = authType === 'jwt'
747
- ? await this.read(destination, 'client', () => serviceKeyStore.getAuthorizationConfig(destination))
1077
+ ? await recorder.read('client')
748
1078
  : null;
749
1079
  provider = (0, destinations_1.handedOverProvider)(destination, authType, secret, (0, bindingOf_1.destinationBinding)(authType, 'none', stated, client));
750
1080
  }
751
1081
  this.logger.debug(`[AuthBroker] Provider built for ${destination}`, {
752
1082
  authType,
753
1083
  });
754
- 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`));
755
1123
  }
756
1124
  /**
757
1125
  * The stored secret when it is bound to this destination's resource and
758
1126
  * 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.
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.
761
1137
  */
762
1138
  boundOrDiscarded(destination, stored, binding) {
763
1139
  if (!stored || (0, binding_1.boundHere)(stored, binding))
@@ -765,46 +1141,55 @@ class AuthBroker {
765
1141
  if (present(stored.authorizationToken) ||
766
1142
  present(stored.sessionCookies) ||
767
1143
  present(stored.refreshToken)) {
768
- 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
+ }
769
1150
  }
770
1151
  return null;
771
1152
  }
772
1153
  /**
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.
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.
777
1165
  */
778
- flush() {
779
- return this.writer.flush();
1166
+ flush(options) {
1167
+ return joined(this.flushSlot, () => this.writer.flush(), options?.signal);
780
1168
  }
781
1169
  /**
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.
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.
789
1173
  *
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.
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.
804
1188
  * - A destination the key store now states as `basic` or `snc` is not
805
1189
  * written: those obtain no session secret.
806
1190
  */
807
- async writeSecret(destination, { result, binding, carry }) {
1191
+ async writeSecret(destination, write) {
1192
+ const { binding } = write;
808
1193
  const serviceKeyStore = this.serviceKeyStore;
809
1194
  const means = serviceKeyStore
810
1195
  ? await this.read(destination, 'means', () => serviceKeyStore.getConnectionConfig(destination))
@@ -813,49 +1198,39 @@ class AuthBroker {
813
1198
  this.logger.warn(`[AuthBroker] Not written: ${destination} is a ${means.authType} destination, which holds no session secret`);
814
1199
  return;
815
1200
  }
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
- };
824
- }
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
- };
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;
838
1205
  }
839
- if (binding.issuedFor !== undefined)
840
- secret.issuedFor = binding.issuedFor;
841
- if (binding.issuedBy !== undefined)
842
- 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
+ });
843
1216
  await this.sessionStore.saveSession(destination, secret);
844
1217
  this.logger.info(`[AuthBroker] Session secret saved for ${destination}`, {
845
- credential: secret.sessionCookies !== undefined ? 'cookies' : 'token',
846
- hasRefreshToken: !!secret.refreshToken,
847
- expiresAt: result.expiresAt,
1218
+ credential: write.cookies ? 'cookies' : 'token',
1219
+ hasRefreshToken: present(secret.refreshToken),
1220
+ expiresAt: write.expiresAt,
848
1221
  });
849
1222
  }
850
1223
  /**
851
1224
  * An `ITokenRefresher` for one destination, for injection into a connection:
852
1225
  * `getToken()` is the broker's `getToken`, `refreshToken()` its forced
853
- * `refreshToken`.
1226
+ * `refreshToken` — each call a waiter with `options.signal`, so a
1227
+ * refresher held by a session ends with it.
854
1228
  */
855
- createTokenRefresher(destination) {
1229
+ createTokenRefresher(destination, options) {
1230
+ const signal = options?.signal;
856
1231
  return {
857
- getToken: () => this.getToken(destination),
858
- refreshToken: () => this.refreshToken(destination),
1232
+ getToken: () => this.getToken(destination, { signal }),
1233
+ refreshToken: () => this.refreshToken(destination, { signal }),
859
1234
  };
860
1235
  }
861
1236
  }