@spine-event-engine/auth 2.0.0-snapshot.26 → 2.0.0-snapshot.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +102 -11
  2. package/REFERENCE.md +125 -3
  3. package/dist/index.d.ts +15 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/oidc/contracts.d.ts +18 -4
  8. package/dist/oidc/contracts.d.ts.map +1 -1
  9. package/dist/oidc/flow-store.d.ts +143 -0
  10. package/dist/oidc/flow-store.d.ts.map +1 -0
  11. package/dist/oidc/flow-store.js +184 -0
  12. package/dist/oidc/flow-store.js.map +1 -0
  13. package/dist/oidc/index.d.ts +29 -9
  14. package/dist/oidc/index.d.ts.map +1 -1
  15. package/dist/oidc/index.js +611 -152
  16. package/dist/oidc/index.js.map +1 -1
  17. package/dist/oidc/persistent-flow-store.d.ts +117 -0
  18. package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
  19. package/dist/oidc/persistent-flow-store.js +413 -0
  20. package/dist/oidc/persistent-flow-store.js.map +1 -0
  21. package/dist/oidc/records.d.ts +15 -0
  22. package/dist/oidc/records.d.ts.map +1 -0
  23. package/dist/oidc/records.js +91 -0
  24. package/dist/oidc/records.js.map +1 -0
  25. package/dist/providers/index.d.ts +10 -5
  26. package/dist/providers/index.d.ts.map +1 -1
  27. package/dist/providers/index.js +103 -30
  28. package/dist/providers/index.js.map +1 -1
  29. package/dist/sessions/cookies.d.ts +46 -8
  30. package/dist/sessions/cookies.d.ts.map +1 -1
  31. package/dist/sessions/cookies.js +269 -47
  32. package/dist/sessions/cookies.js.map +1 -1
  33. package/dist/sessions/opaque-session-store.d.ts +143 -0
  34. package/dist/sessions/opaque-session-store.d.ts.map +1 -0
  35. package/dist/sessions/opaque-session-store.js +185 -0
  36. package/dist/sessions/opaque-session-store.js.map +1 -0
  37. package/dist/sessions/opaque.d.ts +93 -106
  38. package/dist/sessions/opaque.d.ts.map +1 -1
  39. package/dist/sessions/opaque.js +501 -232
  40. package/dist/sessions/opaque.js.map +1 -1
  41. package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
  42. package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
  43. package/dist/sessions/persistent-opaque-session-store.js +333 -0
  44. package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
  45. package/dist/sessions/persistent-token-revocations.d.ts +90 -0
  46. package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
  47. package/dist/sessions/persistent-token-revocations.js +422 -0
  48. package/dist/sessions/persistent-token-revocations.js.map +1 -0
  49. package/dist/sessions/session-lifetime.d.ts +67 -0
  50. package/dist/sessions/session-lifetime.d.ts.map +1 -0
  51. package/dist/sessions/session-lifetime.js +165 -0
  52. package/dist/sessions/session-lifetime.js.map +1 -0
  53. package/dist/sessions/session-record.d.ts +15 -0
  54. package/dist/sessions/session-record.d.ts.map +1 -0
  55. package/dist/sessions/session-record.js +109 -0
  56. package/dist/sessions/session-record.js.map +1 -0
  57. package/dist/sessions/signed.d.ts +5 -4
  58. package/dist/sessions/signed.d.ts.map +1 -1
  59. package/dist/sessions/signed.js +212 -74
  60. package/dist/sessions/signed.js.map +1 -1
  61. package/dist/subscriptions/index.d.ts.map +1 -1
  62. package/dist/subscriptions/index.js +260 -26
  63. package/dist/subscriptions/index.js.map +1 -1
  64. package/dist/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +20 -6
@@ -12,9 +12,14 @@
12
12
  * the License.
13
13
  */
14
14
  import { Time } from "@spine-event-engine/core/time";
15
- import { createHash, randomBytes as nodeRandomBytes, timingSafeEqual } from "node:crypto";
15
+ import { createHash, randomBytes as nodeRandomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
16
16
  import { create } from "@bufbuild/protobuf";
17
17
  import { TimestampSchema } from "@bufbuild/protobuf/wkt";
18
+ import { OidcLoginGrantSchema, OidcLoginTransactionSchema, } from "@spine-event-engine/proto/auth";
19
+ import { Configuration, buildAuthorizationUrl, calculatePKCECodeChallenge } from "openid-client";
20
+ import { MemoryOidcFlowStore } from "./flow-store.js";
21
+ import { OidcFlowKeys } from "./records.js";
22
+ import { SessionRecords } from "../sessions/session-record.js";
18
23
  const RANDOM_BYTES = 32;
19
24
  const DEFAULT_TRANSACTION_TTL = 5 * 60 * 1_000;
20
25
  const DEFAULT_GRANT_TTL = 60 * 1_000;
@@ -28,10 +33,12 @@ const RFC7636_VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
28
33
  /**
29
34
  * Bounded, framework-neutral authorization-code transaction manager.
30
35
  *
31
- * It owns finite start, callback, and one-time application-session exchange
36
+ * It manages finite start, callback, and one-time application-session exchange
32
37
  * transitions; HTTP endpoints and provider discovery remain application work.
33
38
  */
34
39
  export class OidcFlow {
40
+ #store;
41
+ #localStore;
35
42
  #authorizationEndpoint;
36
43
  #callbackUri;
37
44
  #clientId;
@@ -45,15 +52,18 @@ export class OidcFlow {
45
52
  #random;
46
53
  #transactionTtlMilliseconds;
47
54
  #grantTtlMilliseconds;
48
- #maxTransactions;
49
- #maxGrants;
50
55
  #collisionAttempts;
51
56
  #operationTimeoutMilliseconds;
52
57
  #maxAuthorizationUrlLength;
53
58
  #transactions = new Map();
54
59
  #grants = new Map();
55
60
  #callbacks = new Set();
61
+ #rawCollaborators = new Set();
62
+ #lateIssuerFailure;
56
63
  #closed = false;
64
+ #active = 0;
65
+ #drained;
66
+ #closing;
57
67
  /**
58
68
  * Creates a bounded authorization-code flow.
59
69
  * @param options The trusted provider, callback, transaction, grant, and session settings.
@@ -74,31 +84,58 @@ export class OidcFlow {
74
84
  const limits = OidcFlowValues.limits(options);
75
85
  this.#transactionTtlMilliseconds = limits.transactionTtlMilliseconds;
76
86
  this.#grantTtlMilliseconds = limits.grantTtlMilliseconds;
77
- this.#maxTransactions = limits.maxTransactions;
78
- this.#maxGrants = limits.maxGrants;
79
87
  this.#collisionAttempts = limits.collisionAttempts;
80
88
  this.#operationTimeoutMilliseconds = limits.operationTimeoutMilliseconds;
81
89
  this.#maxAuthorizationUrlLength = limits.maxAuthorizationUrlLength;
90
+ this.#localStore = this.#selectMemoryStore(options, limits);
91
+ const selectedStore = options.store ?? this.#localStore;
92
+ if (selectedStore === undefined)
93
+ throw new Error("OIDC flow store is unavailable.");
94
+ this.#store = selectedStore;
95
+ if (typeof this.#store.flowId !== "string" || this.#store.flowId.trim().length === 0)
96
+ throw new TypeError("OIDC flow store must provide a nonempty flow identity.");
97
+ }
98
+ /**
99
+ * Selects process-local storage only when no store was injected.
100
+ * @param options Flow configuration and optional injected store.
101
+ * @param limits Validated memory capacity settings.
102
+ * @returns A local store, or undefined when the caller supplied one.
103
+ */
104
+ #selectMemoryStore(options, limits) {
105
+ if (options.store !== undefined) {
106
+ if (options.maxTransactions !== undefined || options.maxGrants !== undefined)
107
+ throw new TypeError("Memory-only OIDC capacity options require the default store.");
108
+ return undefined;
109
+ }
110
+ return new MemoryOidcFlowStore(randomUUID(), () => OidcFlowValues.timestamp(this.#clock.now()), limits.maxTransactions, limits.maxGrants);
82
111
  }
83
112
  /**
84
113
  * Starts one finite authorization-code transaction without exposing any application credential.
85
- * @param input The browser redirect and requested scopes.
114
+ * @param input The browser challenge and post-login redirect.
115
+ * @param signal Optional host request cancellation; ordinary abort rejects with its reason.
86
116
  * @returns The authorization URL and state, or a rejection.
87
117
  */
88
- start(input) {
118
+ async start(input, signal) {
119
+ return this.#run(() => this.#start(input, signal));
120
+ }
121
+ /**
122
+ * Validates a start request and retries bounded entropy collisions.
123
+ * @param input Browser challenge and allowed redirect.
124
+ * @returns A persisted start or a rejected attempt.
125
+ */
126
+ async #start(input, signal) {
89
127
  if (this.#isClosed())
90
128
  return OidcFlowValues.rejected("closed");
129
+ signal?.throwIfAborted();
91
130
  const request = OidcFlowValues.snapshotStartInput(input);
92
131
  if (request === undefined ||
93
132
  !OidcFlowValues.validStartInput(request, this.#allowedPostLoginRedirects))
94
133
  return OidcFlowValues.rejected("invalid-input");
95
134
  const now = this.#now();
96
- if (now === undefined)
97
- return OidcFlowValues.rejected("closed");
98
- if (this.#closed)
135
+ if (now === undefined || this.#isClosed())
99
136
  return OidcFlowValues.rejected("closed");
100
137
  this.#sweepExpired(now);
101
- if (this.#transactions.size >= this.#maxTransactions)
138
+ if (this.#localStore?.transactionsFull())
102
139
  return OidcFlowValues.rejected("capacity-exceeded");
103
140
  for (let attempt = 0; attempt < this.#collisionAttempts; attempt += 1) {
104
141
  const material = this.#transactionMaterial();
@@ -108,32 +145,9 @@ export class OidcFlow {
108
145
  continue;
109
146
  }
110
147
  try {
111
- const current = this.#now();
112
- if (current === undefined)
113
- return OidcFlowValues.rejected("closed");
114
- this.#sweepExpired(current);
115
- if (this.#isClosed())
116
- return OidcFlowValues.rejected("closed");
117
- if (this.#transactions.size >= this.#maxTransactions)
118
- return OidcFlowValues.rejected("capacity-exceeded");
119
- if (this.#transactions.has(material.state) || this.#transactionMaterialInUse(material))
120
- continue;
121
- const transactionExpiry = OidcFlowValues.expiryAt(current, this.#transactionTtlMilliseconds);
122
- if (transactionExpiry === undefined) {
123
- this.#failClosed();
124
- return OidcFlowValues.rejected("clock-failure");
125
- }
126
- const authorizationUrl = this.#authorizationUrl(material.state, material.nonce, Buffer.from(material.providerVerifierBytes).toString("base64url"), request.browserCodeChallenge);
127
- if (authorizationUrl === undefined)
128
- return OidcFlowValues.rejected("invalid-input");
129
- this.#transactions.set(material.state, {
130
- nonce: material.nonce,
131
- providerVerifier: Uint8Array.from(material.providerVerifierBytes),
132
- browserCodeChallenge: request.browserCodeChallenge,
133
- postLoginRedirect: request.postLoginRedirect,
134
- expiresAt: transactionExpiry,
135
- });
136
- return Object.freeze({ kind: "started", authorizationUrl, expiresAt: transactionExpiry });
148
+ const result = await this.#attemptStart(material, request, signal);
149
+ if (result !== undefined)
150
+ return result;
137
151
  }
138
152
  finally {
139
153
  material.bytes.forEach((bytes) => bytes.fill(0));
@@ -141,78 +155,269 @@ export class OidcFlow {
141
155
  }
142
156
  return OidcFlowValues.rejected("entropy-exhausted");
143
157
  }
158
+ /**
159
+ * Persists one fully bound transaction before returning its authorization URL.
160
+ * @param material Fresh state, nonce, and verifier bytes.
161
+ * @param request Validated browser start request.
162
+ * @returns A start result, or undefined after a collision.
163
+ */
164
+ async #attemptStart(material, request, signal) {
165
+ const current = this.#now();
166
+ if (current === undefined)
167
+ return OidcFlowValues.rejected("closed");
168
+ this.#sweepExpired(current);
169
+ if (this.#isClosed())
170
+ return OidcFlowValues.rejected("closed");
171
+ if (this.#localStore?.transactionsFull())
172
+ return OidcFlowValues.rejected("capacity-exceeded");
173
+ if (this.#transactions.has(material.state) || this.#transactionMaterialInUse(material))
174
+ return undefined;
175
+ const expiry = OidcFlowValues.expiryAt(current, this.#transactionTtlMilliseconds);
176
+ if (expiry === undefined) {
177
+ this.#failClosed();
178
+ return OidcFlowValues.rejected("clock-failure");
179
+ }
180
+ const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
181
+ const authorizationUrl = await this.#authorizationUrl(material.state, material.nonce, verifier);
182
+ signal?.throwIfAborted();
183
+ if (authorizationUrl === undefined)
184
+ return OidcFlowValues.rejected("invalid-input");
185
+ const record = this.#transactionRecord(material, request, verifier, expiry);
186
+ if (!(await this.#store.insertTransaction(record, signal))) {
187
+ signal?.throwIfAborted();
188
+ return undefined;
189
+ }
190
+ if (signal?.aborted || this.#isClosed()) {
191
+ await this.#compensate(() => this.#store.consumeTransaction(record), signal);
192
+ return OidcFlowValues.rejected("closed");
193
+ }
194
+ this.#retainTransaction(material, expiry);
195
+ return Object.freeze({ kind: "started", authorizationUrl, expiresAt: expiry });
196
+ }
197
+ /**
198
+ * Removes a confirmed undisclosed write when its exact record remains.
199
+ * @param remove Conditional deletion of the exact inserted record.
200
+ * @param signal Optional caller cancellation signal.
201
+ * @returns Completion after confirmed compensation.
202
+ */
203
+ async #compensate(remove, signal) {
204
+ try {
205
+ await remove();
206
+ }
207
+ catch (error) {
208
+ throw new AggregateError([signal?.aborted ? signal.reason : new Error("OIDC flow closed."), error], "OIDC confirmed-write compensation failed.");
209
+ }
210
+ signal?.throwIfAborted();
211
+ }
212
+ /**
213
+ * Builds one complete domain transaction from validated material.
214
+ * @param material Fresh state and nonce.
215
+ * @param request Validated browser start request.
216
+ * @param verifier Provider PKCE verifier.
217
+ * @param expiry Exact transaction deadline in milliseconds.
218
+ * @returns Complete typed transaction.
219
+ */
220
+ #transactionRecord(material, request, verifier, expiry) {
221
+ return create(OidcLoginTransactionSchema, {
222
+ lookupDigest: OidcFlowKeys.digest(this.#store.flowId, material.state),
223
+ flowId: this.#store.flowId,
224
+ providerIssuer: this.#providerIssuer,
225
+ clientId: this.#clientId,
226
+ callbackUri: this.#callbackUri,
227
+ nonce: material.nonce,
228
+ providerCodeVerifier: verifier,
229
+ browserCodeChallenge: request.browserCodeChallenge,
230
+ postLoginRedirect: request.postLoginRedirect,
231
+ whenExpires: OidcFlowValues.timestamp(expiry),
232
+ });
233
+ }
234
+ /**
235
+ * Retains only local entropy-collision tracking after confirmed persistence.
236
+ * @param material Fresh state and verifier bytes.
237
+ * @param expiry Exact transaction deadline in milliseconds.
238
+ */
239
+ #retainTransaction(material, expiry) {
240
+ const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
241
+ this.#transactions.set(material.state, {
242
+ nonce: material.nonce,
243
+ verifierChallenge: OidcFlowValues.sha256Base64Url(verifier),
244
+ expiresAt: expiry,
245
+ });
246
+ }
144
247
  /**
145
248
  * Processes a callback state before provider verification or mapping.
146
249
  * A rejected callback never restores its transaction.
147
250
  * @param input The callback state and provider response.
251
+ * @param signal Optional host request cancellation; a consumed state remains consumed.
148
252
  * @returns The callback result, including a one-time grant when accepted.
149
253
  */
150
- async callback(input) {
254
+ async callback(input, signal) {
255
+ return this.#run(() => this.#callback(input, signal));
256
+ }
257
+ /**
258
+ * Consumes accepted state before invoking external identity collaborators.
259
+ * @param input Provider callback value.
260
+ * @returns A grant or a rejected callback.
261
+ */
262
+ async #callback(input, signal) {
263
+ const accepted = await this.#consumeCallback(input, signal);
264
+ if ("kind" in accepted)
265
+ return accepted;
266
+ const { transaction, callback } = accepted;
267
+ if (callback.error !== undefined)
268
+ return OidcFlowValues.callbackRejected("provider-error");
269
+ signal?.throwIfAborted();
270
+ const mapped = await this.#mapCallback(callback.code, transaction, signal);
271
+ if ("kind" in mapped)
272
+ return mapped;
273
+ return this.#createGrant(mapped, transaction, signal);
274
+ }
275
+ /**
276
+ * Validates binding and expiry before atomically consuming a transaction.
277
+ * @param input Browser callback input.
278
+ * @returns Accepted transaction and code or a rejection.
279
+ */
280
+ async #consumeCallback(input, signal) {
151
281
  if (this.#isClosed())
152
282
  return OidcFlowValues.callbackRejected("closed");
283
+ signal?.throwIfAborted();
153
284
  const state = OidcFlowValues.snapshotCallbackState(input);
154
285
  if (state === undefined)
155
286
  return OidcFlowValues.callbackRejected("invalid-input");
156
287
  const now = this.#now();
157
- if (now === undefined)
288
+ if (now === undefined || this.#isClosed())
158
289
  return OidcFlowValues.callbackRejected("closed");
159
- if (this.#isClosed())
160
- return OidcFlowValues.callbackRejected("closed");
161
- const transaction = this.#transactions.get(state);
290
+ const transaction = await this.#store.readTransaction(OidcFlowKeys.digest(this.#store.flowId, state), signal);
291
+ signal?.throwIfAborted();
162
292
  if (transaction === undefined)
163
293
  return OidcFlowValues.callbackRejected("not-found");
164
- this.#transactions.delete(state);
294
+ if (this.#isClosed())
295
+ return OidcFlowValues.callbackRejected("closed");
296
+ const current = this.#now();
297
+ if (current === undefined || this.#isClosed())
298
+ return OidcFlowValues.callbackRejected("closed");
165
299
  const callback = OidcFlowValues.snapshotCallbackInput(input);
166
- if (callback === undefined || !OidcFlowValues.validCallbackInput(callback)) {
167
- transaction.providerVerifier.fill(0);
300
+ if (callback === undefined || !OidcFlowValues.validCallbackInput(callback))
168
301
  return OidcFlowValues.callbackRejected("invalid-input");
302
+ const invalid = this.#callbackValidity(transaction, callback, current);
303
+ if (invalid !== undefined)
304
+ return invalid;
305
+ if (this.#isClosed())
306
+ return OidcFlowValues.callbackRejected("closed");
307
+ if (!(await this.#store.consumeTransaction(transaction, signal))) {
308
+ signal?.throwIfAborted();
309
+ return OidcFlowValues.callbackRejected("not-found");
169
310
  }
170
- if (now >= transaction.expiresAt) {
171
- transaction.providerVerifier.fill(0);
311
+ return this.#settleConsumedCallback(state, transaction, callback, signal);
312
+ }
313
+ /**
314
+ * Rechecks expiry after a confirmed one-use transaction consumption.
315
+ * @param state Browser state whose local collision fact can be discarded.
316
+ * @param transaction Complete consumed transaction.
317
+ * @param callback Validated callback facts.
318
+ * @param signal Optional host request cancellation.
319
+ * @returns Accepted callback or a terminal rejection without state restoration.
320
+ */
321
+ #settleConsumedCallback(state, transaction, callback, signal) {
322
+ this.#transactions.delete(state);
323
+ signal?.throwIfAborted();
324
+ const consumedAt = this.#now();
325
+ if (consumedAt === undefined || this.#isClosed())
326
+ return OidcFlowValues.callbackRejected("closed");
327
+ if (this.#expired(transaction, consumedAt))
172
328
  return OidcFlowValues.callbackRejected("expired");
173
- }
174
- if (callback.error !== undefined) {
175
- transaction.providerVerifier.fill(0);
176
- return OidcFlowValues.callbackRejected("provider-error");
177
- }
178
- if (callback.responseIssuer !== undefined && callback.responseIssuer !== this.#providerIssuer) {
179
- transaction.providerVerifier.fill(0);
329
+ return { transaction, callback };
330
+ }
331
+ /**
332
+ * Checks exact expiry and provider binding before transaction consumption.
333
+ * @param transaction Persisted state snapshot.
334
+ * @param callback Validated callback facts.
335
+ * @param current Trusted time sampled after storage read.
336
+ * @returns Rejection when invalid, otherwise undefined.
337
+ */
338
+ #callbackValidity(transaction, callback, current) {
339
+ if (this.#expired(transaction, current))
340
+ return OidcFlowValues.callbackRejected("expired");
341
+ if (callback.responseIssuer !== undefined && callback.responseIssuer !== this.#providerIssuer)
180
342
  return OidcFlowValues.callbackRejected("issuer-mismatch");
181
- }
182
- const providerVerifier = Buffer.from(transaction.providerVerifier).toString("base64url");
183
- transaction.providerVerifier.fill(0);
184
- const identity = await this.#runBounded((signal) => this.#provider.exchangeAuthorizationCode({
185
- code: callback.code,
343
+ if (!this.#matchesTransaction(transaction))
344
+ return OidcFlowValues.callbackRejected("issuer-mismatch");
345
+ return undefined;
346
+ }
347
+ /**
348
+ * Compares a trusted time against a record's exact deadline.
349
+ * @param record Persisted transaction or grant being checked.
350
+ * @param current Trusted milliseconds after the latest awaited transition.
351
+ * @returns Whether the record has reached its deadline.
352
+ */
353
+ #expired(record, current) {
354
+ return (SessionRecords.compare(OidcFlowValues.timestamp(current), OidcFlowKeys.expiry(record)) >= 0);
355
+ }
356
+ /**
357
+ * Checks a persisted transaction against the current trusted provider configuration.
358
+ * @param transaction Persisted callback binding.
359
+ * @returns Whether issuer, client, and exact callback URI still match.
360
+ */
361
+ #matchesTransaction(transaction) {
362
+ return (transaction.providerIssuer === this.#providerIssuer &&
363
+ transaction.clientId === this.#clientId &&
364
+ transaction.callbackUri === this.#callbackUri);
365
+ }
366
+ /**
367
+ * Verifies a consumed provider code and maps its verified external identity.
368
+ * @param code Provider authorization code.
369
+ * @param transaction Consumed transaction binding and nonce.
370
+ * @returns Resolved application identity or a rejected callback.
371
+ */
372
+ async #mapCallback(code, transaction, signal) {
373
+ signal?.throwIfAborted();
374
+ const identity = await this.#runBounded((bounded) => this.#provider.exchangeAuthorizationCode({
375
+ code,
186
376
  clientId: this.#clientId,
187
377
  callbackUri: this.#callbackUri,
188
- providerCodeVerifier: providerVerifier,
378
+ providerCodeVerifier: transaction.providerCodeVerifier,
189
379
  expectedNonce: transaction.nonce,
190
- signal,
191
- }));
380
+ signal: bounded,
381
+ }), signal);
382
+ signal?.throwIfAborted();
192
383
  if (this.#isClosed())
193
384
  return OidcFlowValues.callbackRejected("closed");
194
- let verified;
195
- try {
196
- verified = OidcFlowValues.validExternalIdentity(identity, this.#providerIssuer);
197
- }
198
- catch {
199
- verified = undefined;
200
- }
385
+ const verified = this.#verifiedIdentity(identity);
201
386
  if (verified === undefined)
202
387
  return OidcFlowValues.callbackRejected("verification-failed");
203
- const resolved = await this.#runBounded((signal) => this.#identityMapping.resolve(verified, signal));
388
+ const resolved = await this.#runBounded((bounded) => this.#identityMapping.resolve(verified, bounded), signal);
389
+ signal?.throwIfAborted();
204
390
  if (this.#isClosed())
205
391
  return OidcFlowValues.callbackRejected("closed");
206
392
  const mapped = OidcFlowValues.snapshotResolvedIdentity(resolved, verified);
207
- if (mapped === undefined)
208
- return OidcFlowValues.callbackRejected("mapping-failed");
393
+ return mapped ?? OidcFlowValues.callbackRejected("mapping-failed");
394
+ }
395
+ /**
396
+ * Validates a provider identity without exposing hostile getter failures.
397
+ * @param identity Provider result.
398
+ * @returns Verified identity or undefined.
399
+ */
400
+ #verifiedIdentity(identity) {
401
+ try {
402
+ return OidcFlowValues.validExternalIdentity(identity, this.#providerIssuer);
403
+ }
404
+ catch {
405
+ return undefined;
406
+ }
407
+ }
408
+ /**
409
+ * Builds and persists a one-use application grant after identity mapping.
410
+ * @param mapped Validated mapped identity.
411
+ * @param transaction Consumed transaction binding.
412
+ * @returns Grant or rejection.
413
+ */
414
+ async #createGrant(mapped, transaction, signal) {
415
+ signal?.throwIfAborted();
209
416
  const current = this.#now();
210
- if (current === undefined)
417
+ if (current === undefined || this.#isClosed())
211
418
  return OidcFlowValues.callbackRejected("closed");
212
419
  this.#sweepGrants(current);
213
- if (this.#isClosed())
214
- return OidcFlowValues.callbackRejected("closed");
215
- if (this.#grants.size >= this.#maxGrants)
420
+ if (this.#localStore?.grantsFull())
216
421
  return OidcFlowValues.callbackRejected("capacity-exceeded");
217
422
  const expiry = OidcFlowValues.expiryAt(current, this.#grantTtlMilliseconds);
218
423
  if (expiry === undefined) {
@@ -220,16 +425,32 @@ export class OidcFlow {
220
425
  return OidcFlowValues.callbackRejected("clock-failure");
221
426
  }
222
427
  const grant = this.#nextGrant();
223
- if (grant === undefined)
224
- return OidcFlowValues.callbackRejected(this.#isClosed() ? "closed" : "entropy-exhausted");
225
- if (this.#isClosed() || this.#grants.size >= this.#maxGrants || this.#grants.has(grant))
428
+ if (grant === undefined ||
429
+ this.#isClosed() ||
430
+ this.#localStore?.grantsFull() ||
431
+ this.#grants.has(grant))
226
432
  return OidcFlowValues.callbackRejected(this.#isClosed() ? "closed" : "entropy-exhausted");
227
- this.#grants.set(grant, {
228
- identity: mapped,
229
- browserCodeChallenge: transaction.browserCodeChallenge,
230
- postLoginRedirect: transaction.postLoginRedirect,
231
- expiresAt: expiry,
232
- });
433
+ return this.#persistGrant(grant, mapped, transaction, expiry, signal);
434
+ }
435
+ /**
436
+ * Persists a grant and compensates only a confirmed insertion after closure.
437
+ * @param grant Random browser grant value.
438
+ * @param mapped Validated mapped identity.
439
+ * @param transaction Consumed transaction binding.
440
+ * @param expiry Exact grant deadline in milliseconds.
441
+ * @returns Grant or rejection.
442
+ */
443
+ async #persistGrant(grant, mapped, transaction, expiry, signal) {
444
+ const record = this.#grantRecord(grant, mapped, transaction, expiry);
445
+ if (!(await this.#store.insertGrant(record, signal))) {
446
+ signal?.throwIfAborted();
447
+ return OidcFlowValues.callbackRejected("entropy-exhausted");
448
+ }
449
+ if (signal?.aborted || this.#isClosed()) {
450
+ await this.#compensate(() => this.#store.consumeGrant(record), signal);
451
+ return OidcFlowValues.callbackRejected("closed");
452
+ }
453
+ this.#grants.set(grant, expiry);
233
454
  return Object.freeze({
234
455
  kind: "granted",
235
456
  grant,
@@ -238,32 +459,45 @@ export class OidcFlow {
238
459
  });
239
460
  }
240
461
  /**
241
- * Processes a grant before validating browser PKCE proof or issuing a session.
242
- * All failures deliberately have the same result to avoid grant-state enumeration.
462
+ * Builds a typed application grant bound to the consumed transaction.
463
+ * @param grant Random browser grant value.
464
+ * @param mapped Validated mapped identity.
465
+ * @param transaction Consumed transaction binding.
466
+ * @param expiry Exact grant deadline in milliseconds.
467
+ * @returns Complete typed grant record.
468
+ */
469
+ #grantRecord(grant, mapped, transaction, expiry) {
470
+ return create(OidcLoginGrantSchema, {
471
+ lookupDigest: OidcFlowKeys.digest(this.#store.flowId, grant),
472
+ flowId: this.#store.flowId,
473
+ principal: SessionRecords.principal(mapped.principal),
474
+ browserCodeChallenge: transaction.browserCodeChallenge,
475
+ postLoginRedirect: transaction.postLoginRedirect,
476
+ whenExpires: OidcFlowValues.timestamp(expiry),
477
+ });
478
+ }
479
+ /**
480
+ * Validates and consumes a grant before issuing an application session.
481
+ * All failures deliberately have the same result to avoid grant enumeration.
243
482
  * @param input The grant and browser PKCE proof.
483
+ * @param signal Optional host request cancellation; a consumed grant remains consumed.
244
484
  * @returns The issued application session or an enumeration-safe rejection.
245
485
  */
246
- async exchange(input) {
247
- const grantRequest = OidcFlowValues.snapshotGrantExchangeInput(input);
248
- if (this.#isClosed() ||
249
- grantRequest === undefined ||
250
- !OidcFlowValues.validGrant(grantRequest.grant))
251
- return OidcFlowValues.exchangeRejected();
252
- const now = this.#now();
253
- if (now === undefined)
254
- return OidcFlowValues.exchangeRejected();
255
- const grant = this.#grants.get(grantRequest.grant);
486
+ async exchange(input, signal) {
487
+ return this.#run(() => this.#exchange(input, signal));
488
+ }
489
+ /**
490
+ * Issues a session only after winning grant consumption.
491
+ * @param input Browser grant and verifier.
492
+ * @returns An issued session or enumeration-safe rejection.
493
+ */
494
+ async #exchange(input, signal) {
495
+ const grant = await this.#consumeGrant(input, signal);
256
496
  if (grant === undefined)
257
497
  return OidcFlowValues.exchangeRejected();
258
- this.#grants.delete(grantRequest.grant);
259
- const browserCodeVerifier = OidcFlowValues.snapshotBrowserCodeVerifier(input);
260
- if (now >= grant.expiresAt ||
261
- browserCodeVerifier === undefined ||
262
- !OidcFlowValues.validBrowserCodeVerifier(browserCodeVerifier))
263
- return OidcFlowValues.exchangeRejected();
264
- if (!OidcFlowValues.constantTimeEquals(OidcFlowValues.sha256Base64Url(browserCodeVerifier), grant.browserCodeChallenge))
265
- return OidcFlowValues.exchangeRejected();
266
- const issued = await this.#runBounded((signal) => this.#sessionIssuer.issue(grant.identity.principal, signal));
498
+ signal?.throwIfAborted();
499
+ const issued = await this.#runBounded((bounded) => this.#sessionIssuer.issue(OidcFlowKeys.principal(grant), bounded), signal, true);
500
+ signal?.throwIfAborted();
267
501
  const safeIssue = OidcFlowValues.snapshotSessionIssue(issued);
268
502
  if (this.#isClosed() || safeIssue === undefined)
269
503
  return OidcFlowValues.exchangeRejected();
@@ -274,11 +508,70 @@ export class OidcFlow {
274
508
  });
275
509
  }
276
510
  /**
277
- * Closes the flow and discards retained OIDC transaction material.
511
+ * Validates binding, expiry, and browser proof before one atomic grant deletion.
512
+ * @param input Browser grant and verifier.
513
+ * @returns Consumed grant or undefined after rejection.
514
+ */
515
+ async #consumeGrant(input, signal) {
516
+ if (this.#isClosed())
517
+ return undefined;
518
+ signal?.throwIfAborted();
519
+ const request = OidcFlowValues.snapshotGrantExchangeInput(input);
520
+ if (request === undefined || !OidcFlowValues.validGrant(request.grant))
521
+ return undefined;
522
+ const now = this.#now();
523
+ if (now === undefined)
524
+ return undefined;
525
+ const grant = await this.#store.readGrant(OidcFlowKeys.digest(this.#store.flowId, request.grant), signal);
526
+ signal?.throwIfAborted();
527
+ if (grant === undefined)
528
+ return undefined;
529
+ if (this.#isClosed())
530
+ return undefined;
531
+ const current = this.#now();
532
+ if (current === undefined || this.#isClosed())
533
+ return undefined;
534
+ const verifier = OidcFlowValues.snapshotBrowserCodeVerifier(input);
535
+ if (!this.#validGrantProof(grant, verifier, current))
536
+ return undefined;
537
+ if (this.#isClosed())
538
+ return undefined;
539
+ if (!(await this.#store.consumeGrant(grant, signal))) {
540
+ signal?.throwIfAborted();
541
+ return undefined;
542
+ }
543
+ this.#grants.delete(request.grant);
544
+ signal?.throwIfAborted();
545
+ const consumedAt = this.#now();
546
+ if (consumedAt === undefined || this.#isClosed() || this.#expired(grant, consumedAt))
547
+ return undefined;
548
+ return grant;
549
+ }
550
+ /**
551
+ * Checks exact grant expiry and browser PKCE proof before consumption.
552
+ * @param grant Persisted grant snapshot.
553
+ * @param verifier Browser supplied verifier.
554
+ * @param current Trusted time sampled after storage read.
555
+ * @returns Whether the grant may be consumed.
556
+ */
557
+ #validGrantProof(grant, verifier, current) {
558
+ if (SessionRecords.compare(OidcFlowValues.timestamp(current), OidcFlowKeys.expiry(grant)) >= 0 ||
559
+ verifier === undefined ||
560
+ !OidcFlowValues.validBrowserCodeVerifier(verifier))
561
+ return false;
562
+ return OidcFlowValues.constantTimeEquals(OidcFlowValues.sha256Base64Url(verifier), grant.browserCodeChallenge);
563
+ }
564
+ /**
565
+ * Closes the flow after admitted work and original collaborator promises settle.
566
+ *
567
+ * An injected store and factory remain open. A late distinct issuer failure
568
+ * rejects this close operation; repeated calls retain that result.
569
+ *
570
+ * @returns Completion after raw work drains and local memory closes.
278
571
  */
279
572
  close() {
280
- if (this.#closed)
281
- return;
573
+ if (this.#closing !== undefined)
574
+ return this.#closing;
282
575
  this.#closed = true;
283
576
  this.#clearTransactions();
284
577
  this.#grants.clear();
@@ -286,24 +579,102 @@ export class OidcFlow {
286
579
  controller.abort();
287
580
  });
288
581
  this.#callbacks.clear();
582
+ this.#closing = this.#finishClose();
583
+ return this.#closing;
584
+ }
585
+ /**
586
+ * Removes eligible records through the selected flow store.
587
+ *
588
+ * @param cutoff Trusted current-or-earlier exact deadline.
589
+ * @param signal Optional cancellation.
590
+ * @returns Confirmed transaction and grant removals in this pass.
591
+ */
592
+ purgeExpired(cutoff, signal) {
593
+ if (this.#isClosed())
594
+ return Promise.reject(new Error("OIDC flow is closed."));
595
+ return this.#run(() => this.#store.purgeExpired(cutoff, signal));
596
+ }
597
+ /**
598
+ * Waits for admitted work, then releases only internally selected memory.
599
+ *
600
+ * @returns Completion after all admitted operations settle.
601
+ */
602
+ async #finishClose() {
603
+ if (this.#active > 0)
604
+ await new Promise((resolve) => (this.#drained = resolve));
605
+ while (this.#rawCollaborators.size > 0)
606
+ await Promise.allSettled([...this.#rawCollaborators]);
607
+ let closeFailure;
608
+ try {
609
+ await this.#localStore?.close();
610
+ }
611
+ catch (error) {
612
+ closeFailure = error;
613
+ }
614
+ if (this.#lateIssuerFailure !== undefined && closeFailure !== undefined)
615
+ throw new AggregateError([this.#lateIssuerFailure.reason, closeFailure], "OIDC flow close failed.");
616
+ if (this.#lateIssuerFailure !== undefined)
617
+ throw this.#lateIssuerFailure.reason;
618
+ // A store may reject with a non-Error reason; preserve its exact failure.
619
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
620
+ if (closeFailure !== undefined)
621
+ throw closeFailure;
622
+ }
623
+ /**
624
+ * Fences admitted asynchronous flow work against closure.
625
+ *
626
+ * @typeParam T Operation result.
627
+ * @param work Operation body.
628
+ * @returns Completion of admitted work.
629
+ */
630
+ async #run(work) {
631
+ if (this.#closed)
632
+ return work();
633
+ this.#active += 1;
634
+ try {
635
+ return await work();
636
+ }
637
+ finally {
638
+ this.#active -= 1;
639
+ if (this.#active === 0)
640
+ this.#drained?.();
641
+ }
289
642
  }
290
643
  #isClosed() {
291
644
  return this.#closed;
292
645
  }
293
- #authorizationUrl(state, nonce, providerVerifier, browserCodeChallenge) {
294
- // The provider verifier is deliberately not serialized. Its S256 challenge is.
295
- const url = new URL(this.#authorizationEndpoint);
296
- const parameters = url.searchParams;
297
- parameters.set("response_type", "code");
298
- parameters.set("client_id", this.#clientId);
299
- parameters.set("redirect_uri", this.#callbackUri);
300
- parameters.set("scope", this.#scopes.join(" "));
301
- parameters.set("state", state);
302
- parameters.set("nonce", nonce);
303
- parameters.set("code_challenge", OidcFlowValues.sha256Base64Url(providerVerifier));
304
- parameters.set("code_challenge_method", "S256");
305
- // The browser challenge is retained for the later, distinct application-session grant.
306
- void browserCodeChallenge;
646
+ /**
647
+ * Builds one S256 authorization URL with the maintained OIDC client library.
648
+ *
649
+ * @param state Random state retained in the transaction.
650
+ * @param nonce Random ID-token nonce.
651
+ * @param providerVerifier Provider PKCE verifier.
652
+ * @returns Bounded URL, if representable under configured limits.
653
+ */
654
+ async #authorizationUrl(state, nonce, providerVerifier) {
655
+ const endpoint = new URL(this.#authorizationEndpoint);
656
+ for (const name of [
657
+ "response_type",
658
+ "client_id",
659
+ "redirect_uri",
660
+ "scope",
661
+ "state",
662
+ "nonce",
663
+ "code_challenge",
664
+ "code_challenge_method",
665
+ ])
666
+ endpoint.searchParams.delete(name);
667
+ const config = new Configuration({ issuer: this.#providerIssuer, authorization_endpoint: endpoint.toString() }, this.#clientId);
668
+ const url = buildAuthorizationUrl(config, {
669
+ response_type: "code",
670
+ client_id: this.#clientId,
671
+ redirect_uri: this.#callbackUri,
672
+ scope: this.#scopes.join(" "),
673
+ state,
674
+ nonce,
675
+ code_challenge: await calculatePKCECodeChallenge(providerVerifier),
676
+ code_challenge_method: "S256",
677
+ });
307
678
  const serialized = url.toString();
308
679
  return serialized.length <= this.#maxAuthorizationUrlLength ? serialized : undefined;
309
680
  }
@@ -338,39 +709,120 @@ export class OidcFlow {
338
709
  }
339
710
  }
340
711
  /**
341
- * Runs one provider callback under the configured timeout and shutdown signal.
712
+ * Runs one collaborator under the configured timeout and request signal.
342
713
  * @typeParam T The successful callback result.
343
714
  * @param operation The callback to run with a cancellation signal.
715
+ * @param signal Optional host request cancellation.
716
+ * @param issuer Whether distinct issuer failures must remain observable.
344
717
  * @returns The callback result, or undefined after timeout or closure.
345
718
  */
346
- async #runBounded(operation) {
719
+ async #runBounded(operation, signal, issuer = false) {
720
+ if (this.#isClosed())
721
+ return undefined;
722
+ signal?.throwIfAborted();
347
723
  const controller = new AbortController();
724
+ const onAbort = () => {
725
+ controller.abort(signal?.reason);
726
+ };
727
+ signal?.addEventListener("abort", onAbort, { once: true });
728
+ if (signal?.aborted)
729
+ onAbort();
348
730
  this.#callbacks.add(controller);
349
- let timer;
731
+ const timer = setTimeout(() => {
732
+ controller.abort();
733
+ }, this.#operationTimeoutMilliseconds);
350
734
  try {
351
- const aborted = new Promise((resolve) => {
352
- controller.signal.addEventListener("abort", () => {
353
- resolve(undefined);
354
- }, { once: true });
355
- });
356
- const timeout = new Promise((resolve) => {
357
- timer = setTimeout(() => {
358
- controller.abort();
359
- resolve(undefined);
360
- }, this.#operationTimeoutMilliseconds);
361
- });
362
- return await Promise.race([operation(controller.signal), timeout, aborted]);
735
+ return await this.#awaitBounded(operation, controller.signal, issuer);
363
736
  }
364
- catch {
737
+ catch (error) {
738
+ if (issuer)
739
+ throw error;
365
740
  return undefined;
366
741
  }
367
742
  finally {
368
- if (timer !== undefined)
369
- clearTimeout(timer);
743
+ clearTimeout(timer);
744
+ signal?.removeEventListener("abort", onAbort);
370
745
  this.#callbacks.delete(controller);
371
746
  controller.abort();
372
747
  }
373
748
  }
749
+ /**
750
+ * Races a tracked original collaborator with its cancellation signal.
751
+ * @typeParam T Collaborator result.
752
+ * @param operation Collaborator invoked with bounded signal.
753
+ * @param signal Bounded cancellation signal.
754
+ * @param issuer Whether distinct issuer failures remain observable.
755
+ * @returns Collaborator result or undefined after cancellation.
756
+ */
757
+ #awaitBounded(operation, signal, issuer) {
758
+ const returned = { done: false };
759
+ const raw = Promise.resolve().then(() => {
760
+ signal.throwIfAborted();
761
+ return operation(signal);
762
+ });
763
+ const observed = this.#observeRaw(raw, signal, issuer, returned);
764
+ this.#trackRaw(observed);
765
+ return Promise.race([observed, this.#aborted(signal)]).then((outcome) => {
766
+ returned.done = true;
767
+ if (outcome === undefined) {
768
+ if (returned.failure !== undefined)
769
+ this.#lateIssuerFailure ??= returned.failure;
770
+ return undefined;
771
+ }
772
+ if (outcome.kind === "failed")
773
+ throw outcome.reason;
774
+ return outcome.kind === "value" ? outcome.value : undefined;
775
+ });
776
+ }
777
+ /**
778
+ * Resolves when a bounded collaborator signal aborts.
779
+ * @param signal Bounded cancellation signal.
780
+ * @returns Undefined once cancelled.
781
+ */
782
+ #aborted(signal) {
783
+ return new Promise((resolve) => {
784
+ if (signal.aborted)
785
+ resolve(undefined);
786
+ else
787
+ signal.addEventListener("abort", () => {
788
+ resolve(undefined);
789
+ }, { once: true });
790
+ });
791
+ }
792
+ /**
793
+ * Tracks the original collaborator promise until it really settles.
794
+ * @typeParam T Collaborator result.
795
+ * @param raw Original collaborator promise.
796
+ */
797
+ #trackRaw(raw) {
798
+ this.#rawCollaborators.add(raw);
799
+ void raw.then(() => {
800
+ this.#rawCollaborators.delete(raw);
801
+ }, () => {
802
+ this.#rawCollaborators.delete(raw);
803
+ });
804
+ }
805
+ /**
806
+ * Preserves a late issuer failure after a caller-facing timeout or abort.
807
+ * @typeParam T Collaborator result.
808
+ * @param raw Original collaborator promise.
809
+ * @param signal Exact cancellation reason supplied to the collaborator.
810
+ * @param issuer Whether this is a durable session issuer.
811
+ * @param returned Caller-facing settlement and any issuer rejection.
812
+ * @returns The tagged collaborator outcome.
813
+ */
814
+ #observeRaw(raw, signal, issuer, returned) {
815
+ return raw.then((value) => ({ kind: "value", value }), (error) => {
816
+ if (!issuer || (signal.aborted && error === signal.reason))
817
+ return { kind: "declined" };
818
+ const failure = { reason: error };
819
+ if (returned.done)
820
+ this.#lateIssuerFailure ??= failure;
821
+ else
822
+ returned.failure = failure;
823
+ return { kind: "failed", reason: error };
824
+ });
825
+ }
374
826
  #nextGrant() {
375
827
  for (let attempt = 0; attempt < this.#collisionAttempts; attempt += 1) {
376
828
  let bytes;
@@ -394,8 +846,7 @@ export class OidcFlow {
394
846
  #transactionMaterialInUse(material) {
395
847
  const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
396
848
  const challenge = OidcFlowValues.sha256Base64Url(verifier);
397
- return [...this.#transactions.values()].some((transaction) => transaction.nonce === material.nonce ||
398
- OidcFlowValues.sha256Base64Url(Buffer.from(transaction.providerVerifier).toString("base64url")) === challenge);
849
+ return [...this.#transactions.values()].some((transaction) => transaction.nonce === material.nonce || transaction.verifierChallenge === challenge);
399
850
  }
400
851
  #now() {
401
852
  try {
@@ -412,19 +863,14 @@ export class OidcFlow {
412
863
  #sweepExpired(now) {
413
864
  for (const [state, transaction] of this.#transactions)
414
865
  if (now >= transaction.expiresAt)
415
- this.#dropTransaction(state, transaction);
866
+ this.#transactions.delete(state);
416
867
  }
417
868
  #sweepGrants(now) {
418
- for (const [grant, record] of this.#grants)
419
- if (now >= record.expiresAt)
869
+ for (const [grant, expiresAt] of this.#grants)
870
+ if (now >= expiresAt)
420
871
  this.#grants.delete(grant);
421
872
  }
422
- #dropTransaction(state, transaction) {
423
- this.#transactions.delete(state);
424
- transaction.providerVerifier.fill(0);
425
- }
426
873
  #clearTransactions() {
427
- this.#transactions.forEach((transaction) => transaction.providerVerifier.fill(0));
428
874
  this.#transactions.clear();
429
875
  }
430
876
  #failClosed() {
@@ -1019,6 +1465,19 @@ const OidcFlowValues = Object.freeze({
1019
1465
  const value = now + ttl;
1020
1466
  return OidcFlowValues.validTimestamp(value) ? value : undefined;
1021
1467
  },
1468
+ /**
1469
+ * Converts safe epoch milliseconds to an exact Protobuf deadline.
1470
+ *
1471
+ * @param milliseconds Valid Unix epoch milliseconds.
1472
+ * @returns Timestamp without negative-time truncation.
1473
+ */
1474
+ timestamp(milliseconds) {
1475
+ const seconds = Math.floor(milliseconds / 1_000);
1476
+ return create(TimestampSchema, {
1477
+ seconds: BigInt(seconds),
1478
+ nanos: (milliseconds - seconds * 1_000) * 1_000_000,
1479
+ });
1480
+ },
1022
1481
  /**
1023
1482
  * Creates a URL-safe SHA-256 challenge from PKCE material.
1024
1483
  * @param value The ASCII PKCE verifier material to hash.