@spine-event-engine/auth 2.0.0-snapshot.25 → 2.0.0-snapshot.28

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 (82) hide show
  1. package/README.md +128 -11
  2. package/REFERENCE.md +166 -12
  3. package/dist/gateway/dynamic-unary-forwarder.d.ts +62 -3
  4. package/dist/gateway/dynamic-unary-forwarder.d.ts.map +1 -1
  5. package/dist/gateway/dynamic-unary-forwarder.js +189 -10
  6. package/dist/gateway/dynamic-unary-forwarder.js.map +1 -1
  7. package/dist/gateway/index.d.ts +5 -0
  8. package/dist/gateway/index.d.ts.map +1 -1
  9. package/dist/gateway/index.js +65 -7
  10. package/dist/gateway/index.js.map +1 -1
  11. package/dist/index.d.ts +60 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/native/index.d.ts +43 -3
  16. package/dist/native/index.d.ts.map +1 -1
  17. package/dist/native/index.js +178 -32
  18. package/dist/native/index.js.map +1 -1
  19. package/dist/oidc/contracts.d.ts +18 -4
  20. package/dist/oidc/contracts.d.ts.map +1 -1
  21. package/dist/oidc/flow-store.d.ts +143 -0
  22. package/dist/oidc/flow-store.d.ts.map +1 -0
  23. package/dist/oidc/flow-store.js +184 -0
  24. package/dist/oidc/flow-store.js.map +1 -0
  25. package/dist/oidc/index.d.ts +29 -9
  26. package/dist/oidc/index.d.ts.map +1 -1
  27. package/dist/oidc/index.js +611 -152
  28. package/dist/oidc/index.js.map +1 -1
  29. package/dist/oidc/persistent-flow-store.d.ts +117 -0
  30. package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
  31. package/dist/oidc/persistent-flow-store.js +413 -0
  32. package/dist/oidc/persistent-flow-store.js.map +1 -0
  33. package/dist/oidc/records.d.ts +15 -0
  34. package/dist/oidc/records.d.ts.map +1 -0
  35. package/dist/oidc/records.js +91 -0
  36. package/dist/oidc/records.js.map +1 -0
  37. package/dist/providers/index.d.ts +10 -5
  38. package/dist/providers/index.d.ts.map +1 -1
  39. package/dist/providers/index.js +103 -30
  40. package/dist/providers/index.js.map +1 -1
  41. package/dist/request/index.d.ts +3 -0
  42. package/dist/request/index.d.ts.map +1 -1
  43. package/dist/request/index.js +17 -1
  44. package/dist/request/index.js.map +1 -1
  45. package/dist/sessions/cookies.d.ts +46 -8
  46. package/dist/sessions/cookies.d.ts.map +1 -1
  47. package/dist/sessions/cookies.js +269 -47
  48. package/dist/sessions/cookies.js.map +1 -1
  49. package/dist/sessions/opaque-session-store.d.ts +143 -0
  50. package/dist/sessions/opaque-session-store.d.ts.map +1 -0
  51. package/dist/sessions/opaque-session-store.js +185 -0
  52. package/dist/sessions/opaque-session-store.js.map +1 -0
  53. package/dist/sessions/opaque.d.ts +93 -106
  54. package/dist/sessions/opaque.d.ts.map +1 -1
  55. package/dist/sessions/opaque.js +501 -232
  56. package/dist/sessions/opaque.js.map +1 -1
  57. package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
  58. package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
  59. package/dist/sessions/persistent-opaque-session-store.js +333 -0
  60. package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
  61. package/dist/sessions/persistent-token-revocations.d.ts +90 -0
  62. package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
  63. package/dist/sessions/persistent-token-revocations.js +422 -0
  64. package/dist/sessions/persistent-token-revocations.js.map +1 -0
  65. package/dist/sessions/session-lifetime.d.ts +67 -0
  66. package/dist/sessions/session-lifetime.d.ts.map +1 -0
  67. package/dist/sessions/session-lifetime.js +165 -0
  68. package/dist/sessions/session-lifetime.js.map +1 -0
  69. package/dist/sessions/session-record.d.ts +15 -0
  70. package/dist/sessions/session-record.d.ts.map +1 -0
  71. package/dist/sessions/session-record.js +109 -0
  72. package/dist/sessions/session-record.js.map +1 -0
  73. package/dist/sessions/signed.d.ts +5 -4
  74. package/dist/sessions/signed.d.ts.map +1 -1
  75. package/dist/sessions/signed.js +212 -74
  76. package/dist/sessions/signed.js.map +1 -1
  77. package/dist/subscriptions/index.d.ts +35 -3
  78. package/dist/subscriptions/index.d.ts.map +1 -1
  79. package/dist/subscriptions/index.js +680 -134
  80. package/dist/subscriptions/index.js.map +1 -1
  81. package/dist/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +20 -6
@@ -11,311 +11,580 @@
11
11
  * or implied. See the License for the specific language governing permissions and limitations under
12
12
  * the License.
13
13
  */
14
- import { Time } from "@spine-event-engine/core/time";
15
14
  import { create } from "@bufbuild/protobuf";
16
15
  import { TimestampSchema } from "@bufbuild/protobuf/wkt";
17
16
  import { randomBytes as nodeRandomBytes } from "node:crypto";
17
+ import { sealData, unsealData } from "iron-session";
18
+ import { OpaqueSessionRecordSchema, } from "@spine-event-engine/proto/auth";
19
+ import { MemoryOpaqueSessionStore } from "./opaque-session-store.js";
20
+ import { SessionRecords } from "./session-record.js";
21
+ import { SessionLifetimes } from "./session-lifetime.js";
18
22
  /**
19
- * A bounded, terminal in-memory store of random opaque application sessions.
20
- *
21
- * It is intentionally process-local: applications needing durable or shared
22
- * sessions provide another `SessionResolver` implementation.
23
+ * Sealed opaque credentials backed by atomic stable-reference transitions.
23
24
  */
24
25
  export class OpaqueSessions {
25
- records = new Map();
26
- clock;
27
- random;
28
- ttlMilliseconds;
29
- maxSessions;
30
- collisionAttempts;
31
- closed = false;
26
+ #store;
27
+ #internalStore;
28
+ #scope;
29
+ #password;
30
+ #clock;
31
+ #ttlMilliseconds;
32
+ #sealTtl;
33
+ #random;
34
+ #collisionAttempts;
35
+ #lifetimes = new SessionLifetimes(() => this.#now());
36
+ #closed = false;
37
+ #active = 0;
38
+ #drained;
39
+ #closing;
32
40
  /**
33
- * Creates a bounded in-memory session store.
34
- * @param options The optional clock, entropy, lifetime, and capacity settings.
41
+ * Binds already validated policy and an authoritative store.
42
+ *
43
+ * @param options Validated session policy.
44
+ * @param store Selected atomic store.
45
+ * @param internalStore Process-local store created by this strategy, if any.
35
46
  */
36
- constructor(options = {}) {
37
- this.clock = options.clock ?? { now: () => Time.currentTimeMillis() };
38
- this.random = options.randomBytes ?? nodeRandomBytes;
39
- this.ttlMilliseconds = OpaqueSessionValues.positiveSafeInteger(options.ttlMilliseconds ?? 8 * 60 * 60 * 1_000, "ttlMilliseconds");
40
- this.maxSessions = OpaqueSessionValues.positiveSafeInteger(options.maxSessions ?? 10_000, "maxSessions");
41
- this.collisionAttempts = OpaqueSessionValues.positiveSafeInteger(options.collisionAttempts ?? 3, "collisionAttempts");
47
+ constructor(options, store, internalStore) {
48
+ this.#store = store;
49
+ this.#internalStore = internalStore;
50
+ this.#scope = options.scope;
51
+ this.#password = SessionPolicy.password(options.password);
52
+ this.#clock = options.clock;
53
+ this.#ttlMilliseconds = options.ttlMilliseconds;
54
+ this.#sealTtl = Math.ceil(options.ttlMilliseconds / 1_000);
55
+ this.#random = options.randomBytes ?? nodeRandomBytes;
56
+ this.#collisionAttempts = options.collisionAttempts ?? 3;
42
57
  }
43
58
  /**
44
- * Creates a new session unless the terminal or bounded store state rejects it.
45
- * @param principal The authenticated principal to retain.
46
- * @returns The creation outcome and, when accepted, credential and session.
59
+ * Validates sealing and lifetime policy before exposing a usable strategy.
60
+ *
61
+ * @param options Required application policy and optional store.
62
+ * @returns Initialized opaque-session strategy.
47
63
  */
48
- create(principal) {
49
- if (this.isClosed())
50
- return Promise.resolve({ kind: "rejected", reason: "closed" });
51
- const now = this.now();
52
- if (now === undefined)
53
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
54
- this.sweepExpired(now);
55
- if (this.records.size >= this.maxSessions)
56
- return Promise.resolve({ kind: "rejected", reason: "capacity-exceeded" });
57
- const id = this.nextId();
58
- if (id === undefined)
59
- return Promise.resolve({ kind: "rejected", reason: "entropy-exhausted" });
60
- const current = this.now();
61
- if (current === undefined)
62
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
63
- this.sweepExpired(current);
64
- if (this.isClosed())
65
- return Promise.resolve({ kind: "rejected", reason: "closed" });
66
- if (this.records.size >= this.maxSessions) {
67
- return Promise.resolve({ kind: "rejected", reason: "capacity-exceeded" });
68
- }
69
- if (this.records.has(id))
70
- return Promise.resolve({ kind: "rejected", reason: "entropy-exhausted" });
71
- const record = this.record(principal, current);
72
- if (record === undefined)
73
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
74
- if (this.isClosed())
75
- return Promise.resolve({ kind: "rejected", reason: "closed" });
76
- this.records.set(id, record);
77
- return Promise.resolve({
78
- kind: "created",
79
- credential: OpaqueSessionValues.credential(id),
80
- session: OpaqueSessionValues.resolved(record),
64
+ static create(options) {
65
+ return Promise.resolve().then(() => {
66
+ if (typeof options.scope !== "string" || options.scope.length === 0)
67
+ throw new TypeError("Opaque session scope must be nonempty.");
68
+ SessionPolicy.password(options.password);
69
+ SessionPolicy.positive(options.ttlMilliseconds, "ttlMilliseconds");
70
+ SessionPolicy.positive(options.collisionAttempts ?? 3, "collisionAttempts");
71
+ SessionPolicy.positive(options.maxSessions ?? 10_000, "maxSessions");
72
+ if (options.store !== undefined && options.maxSessions !== undefined)
73
+ throw new TypeError("maxSessions applies only when the memory store is selected.");
74
+ const clock = options.clock;
75
+ if (typeof clock !== "object" ||
76
+ clock === null ||
77
+ typeof Reflect.get(clock, "now") !== "function")
78
+ throw new TypeError("Session clock is required.");
79
+ if (options.store !== undefined)
80
+ return new OpaqueSessions(options, options.store);
81
+ const memory = new MemoryOpaqueSessionStore(() => SessionPolicy.timestamp(options.clock.now()), options.maxSessions ?? 10_000);
82
+ return new OpaqueSessions(options, memory, memory);
81
83
  });
82
84
  }
83
85
  /**
84
- * Resolves one currently live cookie credential as a defensive copy.
85
- * @param credentialInput The credential to resolve.
86
- * @returns The live session, or undefined when it is invalid or expired.
86
+ * Creates one sealed credential after an atomic absent insertion.
87
+ *
88
+ * @param principal Complete authenticated identity.
89
+ * @param signal Optional cancellation.
90
+ * @returns Issuance result; cancellation never returns a credential.
87
91
  */
88
- resolve(credentialInput) {
89
- if (this.closed || credentialInput.kind !== "cookie")
90
- return Promise.resolve(undefined);
91
- const now = this.now();
92
- if (now === undefined)
92
+ issue(principal, signal) {
93
+ if (this.#closed)
94
+ return Promise.resolve({ kind: "rejected", reason: "closed" });
95
+ return this.#run(() => this.#issue(principal, signal));
96
+ }
97
+ /**
98
+ * Resolves only the current generation before its exact deadline.
99
+ *
100
+ * @param credentialInput Cookie credential to validate.
101
+ * @param signal Optional cancellation.
102
+ * @returns Detached session, or undefined for invalid or expired credentials.
103
+ */
104
+ resolve(credentialInput, signal) {
105
+ if (this.#closed || credentialInput.kind !== "cookie")
93
106
  return Promise.resolve(undefined);
94
- const record = this.live(credentialInput.value, now);
95
- return Promise.resolve(record === undefined ? undefined : OpaqueSessionValues.resolved(record));
107
+ return this.#run(async () => {
108
+ signal?.throwIfAborted();
109
+ const seal = await this.#unseal(credentialInput.value);
110
+ signal?.throwIfAborted();
111
+ if (this.#closed)
112
+ return undefined;
113
+ if (seal === undefined || this.#expired(seal.expires))
114
+ return undefined;
115
+ const monitor = signal === undefined ? undefined : this.#lifetimes.register(seal.id, signal);
116
+ let retained = false;
117
+ try {
118
+ const record = await this.#store.read(seal.id, signal);
119
+ signal?.throwIfAborted();
120
+ if (this.#isClosed() || monitor?.signal.aborted)
121
+ return undefined;
122
+ if (record === undefined ||
123
+ !this.#matches(record, seal) ||
124
+ this.#expired(SessionRecords.expiry(record)))
125
+ return undefined;
126
+ const resolved = this.#resolved(record);
127
+ if (monitor === undefined)
128
+ return resolved;
129
+ if (!monitor.ready(resolved.expiresAt))
130
+ return undefined;
131
+ retained = true;
132
+ return { ...resolved, invalidated: monitor.signal };
133
+ }
134
+ finally {
135
+ if (!retained)
136
+ monitor?.release();
137
+ }
138
+ });
96
139
  }
97
140
  /**
98
- * Updates a live opaque credential without ever retaining both values.
99
- * @param credentialInput The credential to replace.
100
- * @returns The rotation outcome and, when accepted, the replacement values.
141
+ * Creates a new generation before atomically replacing the current record.
142
+ *
143
+ * @param credentialInput Current cookie credential.
144
+ * @param signal Optional cancellation.
145
+ * @returns One winning replacement or a rejection.
101
146
  */
102
- rotate(credentialInput) {
103
- if (this.isClosed())
147
+ rotate(credentialInput, signal) {
148
+ if (this.#closed)
104
149
  return Promise.resolve({ kind: "rejected", reason: "closed" });
105
150
  if (credentialInput.kind !== "cookie")
106
151
  return Promise.resolve({ kind: "rejected", reason: "unsupported-credential" });
107
- const now = this.now();
108
- if (now === undefined)
109
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
110
- const previous = this.records.get(credentialInput.value);
111
- if (previous === undefined)
112
- return Promise.resolve({ kind: "rejected", reason: "not-found" });
113
- if (this.expired(previous, now)) {
114
- this.records.delete(credentialInput.value);
115
- return Promise.resolve({ kind: "rejected", reason: "expired" });
116
- }
117
- const id = this.nextId();
118
- if (id === undefined)
119
- return Promise.resolve({ kind: "rejected", reason: "entropy-exhausted" });
120
- const current = this.now();
121
- if (current === undefined)
122
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
123
- if (this.isClosed())
124
- return Promise.resolve({ kind: "rejected", reason: "closed" });
125
- if (this.records.get(credentialInput.value) !== previous) {
126
- return Promise.resolve({ kind: "rejected", reason: "not-found" });
127
- }
128
- if (this.expired(previous, current)) {
129
- this.records.delete(credentialInput.value);
130
- return Promise.resolve({ kind: "rejected", reason: "expired" });
131
- }
132
- if (this.records.has(id))
133
- return Promise.resolve({ kind: "rejected", reason: "entropy-exhausted" });
134
- const record = this.record(previous.principal, current);
135
- if (record === undefined)
136
- return Promise.resolve({ kind: "rejected", reason: "clock-failure" });
137
- this.records.delete(credentialInput.value);
138
- this.records.set(id, record);
139
- return Promise.resolve({
140
- kind: "rotated",
141
- credential: OpaqueSessionValues.credential(id),
142
- session: OpaqueSessionValues.resolved(record),
152
+ return this.#run(() => this.#rotate(credentialInput.value, signal));
153
+ }
154
+ /**
155
+ * Removes the stable reference when the supplied seal is still live.
156
+ *
157
+ * @param credentialInput Cookie credential to revoke.
158
+ * @param signal Optional cancellation.
159
+ * @returns Enumeration-safe acknowledgement.
160
+ */
161
+ logout(credentialInput, signal) {
162
+ if (this.#closed || credentialInput.kind !== "cookie")
163
+ return Promise.resolve({ kind: "logged-out" });
164
+ return this.#run(async () => {
165
+ signal?.throwIfAborted();
166
+ const seal = await this.#unseal(credentialInput.value);
167
+ signal?.throwIfAborted();
168
+ if (seal !== undefined && !this.#expired(seal.expires)) {
169
+ try {
170
+ await this.#store.revoke(seal.id, signal);
171
+ signal?.throwIfAborted();
172
+ }
173
+ finally {
174
+ this.#lifetimes.invalidate(seal.id);
175
+ }
176
+ }
177
+ return { kind: "logged-out" };
143
178
  });
144
179
  }
145
180
  /**
146
- * Deletes a cookie session when present without revealing whether it existed.
147
- * @param credentialInput The credential to invalidate.
148
- * @returns An enumeration-safe logout acknowledgement.
181
+ * Removes records at or before a trusted maintenance cutoff.
182
+ *
183
+ * @param cutoff Current-or-earlier exact deadline.
184
+ * @param signal Optional maintenance cancellation.
185
+ * @returns Number of conditionally removed records.
149
186
  */
150
- logout(credentialInput) {
151
- if (!this.closed && credentialInput.kind === "cookie")
152
- this.records.delete(credentialInput.value);
153
- return Promise.resolve({ kind: "logged-out" });
187
+ purgeExpired(cutoff, signal) {
188
+ if (this.#closed)
189
+ return Promise.reject(new Error("Session strategy is closed."));
190
+ return this.#run(() => this.#store.purgeExpired(cutoff, signal));
154
191
  }
155
192
  /**
156
- * Closes the store and drops retained session identity and record references.
157
- * @returns Completes after retained records are dropped.
193
+ * Closes new work and drains admitted work and compensation.
194
+ *
195
+ * @returns Repeatable close completion; injected stores remain open.
158
196
  */
159
197
  close() {
160
- if (this.closed)
161
- return Promise.resolve();
162
- this.closed = true;
163
- this.records.clear();
164
- return Promise.resolve();
198
+ if (this.#closing !== undefined)
199
+ return this.#closing;
200
+ this.#closed = true;
201
+ this.#lifetimes.close();
202
+ this.#closing = this.#finishClose();
203
+ return this.#closing;
165
204
  }
166
205
  /**
167
- * Checks whether the session store has been closed.
168
- * @returns Whether further session operations must be rejected.
206
+ * Waits for admitted work and closes only internally created memory.
207
+ *
208
+ * @returns Completion after local resources are released.
169
209
  */
170
- isClosed() {
171
- return this.closed;
210
+ async #finishClose() {
211
+ if (this.#active > 0)
212
+ await new Promise((resolve) => (this.#drained = resolve));
213
+ await this.#internalStore?.close();
172
214
  }
173
215
  /**
174
- * Creates a bounded session record for an authenticated principal.
175
- * @param principal The identity to store in the session.
176
- * @param now The validated issuance time in milliseconds.
177
- * @returns The record, or undefined if expiry cannot be represented.
216
+ * Keeps asynchronous work admitted through all awaited transitions.
217
+ *
218
+ * @typeParam T Result produced by the admitted operation.
219
+ * @param work Operation body.
220
+ * @returns Operation outcome.
178
221
  */
179
- record(principal, now) {
180
- const expiresAt = now + this.ttlMilliseconds;
181
- if (!Number.isSafeInteger(expiresAt) || !OpaqueSessionValues.timestampValid(expiresAt)) {
182
- this.failClosed();
183
- return undefined;
222
+ async #run(work) {
223
+ this.#active += 1;
224
+ try {
225
+ return await work();
226
+ }
227
+ finally {
228
+ this.#active -= 1;
229
+ if (this.#active === 0)
230
+ this.#drained?.();
184
231
  }
185
- return { principal: OpaqueSessionValues.copyPrincipal(principal), expiresAt };
186
232
  }
187
233
  /**
188
- * Creates an unused opaque credential identifier within the retry limit.
189
- * @returns The identifier, or undefined after entropy failures or collisions.
234
+ * Tries random stable references until an absent insert wins.
235
+ *
236
+ * @param principal Complete identity.
237
+ * @param signal Optional cancellation.
238
+ * @returns Issuance outcome.
190
239
  */
191
- nextId() {
192
- for (let attempt = 0; attempt < this.collisionAttempts; attempt += 1) {
193
- let bytes;
240
+ async #issue(principal, signal) {
241
+ for (let attempt = 0; attempt < this.#collisionAttempts; attempt += 1) {
242
+ signal?.throwIfAborted();
243
+ if (this.#internalStore?.atCapacity())
244
+ return { kind: "rejected", reason: "capacity-exceeded" };
245
+ const candidate = this.#newRecord(principal);
246
+ if (typeof candidate === "string")
247
+ return { kind: "rejected", reason: candidate };
248
+ const seal = await this.#seal(candidate);
249
+ signal?.throwIfAborted();
250
+ if (this.#closed)
251
+ throw new Error("Session strategy closed.");
252
+ let inserted = false;
194
253
  try {
195
- bytes = this.random(32);
196
- }
197
- catch {
198
- continue;
254
+ inserted = await this.#store.insert(candidate);
255
+ if (signal?.aborted || this.#isClosed())
256
+ throw signal?.reason ?? new Error("Session strategy closed.");
199
257
  }
200
- try {
201
- if (bytes.byteLength !== 32)
202
- continue;
203
- const id = Buffer.from(bytes).toString("base64url");
204
- if (!this.records.has(id))
205
- return id;
206
- }
207
- finally {
208
- bytes.fill(0);
258
+ catch (error) {
259
+ if (inserted && (signal?.aborted || this.#isClosed()))
260
+ await this.#compensate(candidate, error);
261
+ throw error;
209
262
  }
263
+ if (inserted)
264
+ return {
265
+ kind: "created",
266
+ credential: SessionPolicy.credential(seal),
267
+ session: this.#resolved(candidate),
268
+ };
210
269
  }
211
- return undefined;
270
+ return {
271
+ kind: "rejected",
272
+ reason: this.#internalStore?.atCapacity() ? "capacity-exceeded" : "entropy-exhausted",
273
+ };
274
+ }
275
+ /**
276
+ * Creates one complete candidate from trusted time and cryptographic bytes.
277
+ *
278
+ * @param principal Complete authenticated identity.
279
+ * @returns Candidate record or a policy failure reason.
280
+ */
281
+ #newRecord(principal) {
282
+ const now = this.#now();
283
+ if (now === undefined)
284
+ return "clock-failure";
285
+ const expires = this.#deadline(now);
286
+ if (expires === undefined)
287
+ return "clock-failure";
288
+ const id = this.#randomValue();
289
+ const generation = this.#randomValue();
290
+ if (id === undefined || generation === undefined)
291
+ return "entropy-exhausted";
292
+ return create(OpaqueSessionRecordSchema, {
293
+ id,
294
+ generation,
295
+ principal: SessionRecords.principal(principal),
296
+ whenExpires: expires,
297
+ });
212
298
  }
213
299
  /**
214
- * Returns a live session record and removes it when expired.
215
- * @param id The opaque credential identifier.
216
- * @param now The validated lookup time in milliseconds.
217
- * @returns The live record, or undefined if absent or expired.
300
+ * Removes only a newly issued expected record after late cancellation.
301
+ *
302
+ * @param record Candidate inserted session.
303
+ * @param cause Cancellation or close failure.
218
304
  */
219
- live(id, now) {
220
- const record = this.records.get(id);
221
- if (record === undefined)
305
+ async #compensate(record, cause) {
306
+ try {
307
+ await this.#store.remove(record);
308
+ }
309
+ catch (error) {
310
+ throw new AggregateError([cause, error], "Session issuance compensation failed.");
311
+ }
312
+ }
313
+ /**
314
+ * Validates a current generation then performs one conditional replacement.
315
+ *
316
+ * @param credential Sealed cookie value.
317
+ * @param signal Optional cancellation.
318
+ * @returns Rotation outcome.
319
+ */
320
+ async #rotate(credential, signal) {
321
+ signal?.throwIfAborted();
322
+ const seal = await this.#unseal(credential);
323
+ signal?.throwIfAborted();
324
+ if (this.#closed)
325
+ return { kind: "rejected", reason: "closed" };
326
+ if (seal === undefined)
327
+ return { kind: "rejected", reason: "not-found" };
328
+ if (this.#expired(seal.expires))
329
+ return { kind: "rejected", reason: "expired" };
330
+ const previous = await this.#store.read(seal.id, signal);
331
+ signal?.throwIfAborted();
332
+ if (this.#isClosed())
333
+ return { kind: "rejected", reason: "closed" };
334
+ if (previous === undefined || !this.#matches(previous, seal))
335
+ return { kind: "rejected", reason: "not-found" };
336
+ if (this.#expired(SessionRecords.expiry(previous)))
337
+ return { kind: "rejected", reason: "expired" };
338
+ return this.#replace(previous, signal);
339
+ }
340
+ /**
341
+ * Creates a new generation before the authoritative conditional update.
342
+ *
343
+ * @param previous Complete expected record.
344
+ * @param signal Optional cancellation.
345
+ * @returns Rotation outcome.
346
+ */
347
+ async #replace(previous, signal) {
348
+ const candidate = this.#replacement(previous);
349
+ if (typeof candidate === "string")
350
+ return { kind: "rejected", reason: candidate };
351
+ const next = candidate;
352
+ const seal = await this.#seal(next);
353
+ signal?.throwIfAborted();
354
+ if (this.#closed)
355
+ return { kind: "rejected", reason: "closed" };
356
+ let replaced = false;
357
+ try {
358
+ replaced = await this.#store.replace(previous, next);
359
+ if (signal?.aborted || this.#isClosed())
360
+ throw signal?.reason ?? new Error("Session strategy closed.");
361
+ }
362
+ catch (error) {
363
+ if (replaced && (signal?.aborted || this.#isClosed()))
364
+ await this.#compensate(next, error);
365
+ throw error;
366
+ }
367
+ return replaced
368
+ ? {
369
+ kind: "rotated",
370
+ credential: SessionPolicy.credential(seal),
371
+ session: this.#resolved(next),
372
+ }
373
+ : { kind: "rejected", reason: "not-found" };
374
+ }
375
+ /**
376
+ * Creates a distinct generation with its exact replacement deadline.
377
+ *
378
+ * @param previous Current complete record.
379
+ * @returns Replacement record or a policy failure reason.
380
+ */
381
+ #replacement(previous) {
382
+ const now = this.#now();
383
+ if (now === undefined)
384
+ return "clock-failure";
385
+ const expires = this.#deadline(now);
386
+ if (expires === undefined)
387
+ return "clock-failure";
388
+ const generation = this.#randomValue();
389
+ if (generation === undefined || generation === previous.generation)
390
+ return "entropy-exhausted";
391
+ return create(OpaqueSessionRecordSchema, { ...previous, generation, whenExpires: expires });
392
+ }
393
+ /**
394
+ * Creates JSON-safe authenticated reference, generation, scope and deadline.
395
+ *
396
+ * @param record Complete record to bind.
397
+ * @returns Iron-session seal.
398
+ */
399
+ #seal(record) {
400
+ const expiry = SessionRecords.expiry(record);
401
+ return sealData({
402
+ id: record.id,
403
+ generation: record.generation,
404
+ scope: this.#scope,
405
+ seconds: expiry.seconds.toString(),
406
+ nanos: expiry.nanos,
407
+ }, { password: this.#password, ttl: this.#sealTtl });
408
+ }
409
+ /**
410
+ * Unseals and validates a scoped JSON-safe payload.
411
+ *
412
+ * @param credential Candidate cookie value.
413
+ * @returns Authenticated payload, if valid.
414
+ */
415
+ async #unseal(credential) {
416
+ const value = await unsealData(credential, {
417
+ password: this.#password,
418
+ ttl: this.#sealTtl,
419
+ });
420
+ if (typeof value !== "object" || value === null)
222
421
  return undefined;
223
- if (!this.expired(record, now))
224
- return record;
225
- this.records.delete(id);
226
- return undefined;
422
+ const fields = value;
423
+ if (fields.scope !== this.#scope ||
424
+ typeof fields.id !== "string" ||
425
+ typeof fields.generation !== "string" ||
426
+ typeof fields.seconds !== "string" ||
427
+ typeof fields.nanos !== "number" ||
428
+ !/^-?\d+$/.test(fields.seconds))
429
+ return undefined;
430
+ try {
431
+ return {
432
+ id: fields.id,
433
+ generation: fields.generation,
434
+ expires: SessionRecords.deadline(create(TimestampSchema, {
435
+ seconds: BigInt(fields.seconds),
436
+ nanos: fields.nanos,
437
+ })),
438
+ };
439
+ }
440
+ catch {
441
+ return undefined;
442
+ }
227
443
  }
228
444
  /**
229
- * Removes every session that has expired by the supplied time.
230
- * @param now The validated sweep time in milliseconds.
445
+ * Matches a seal to the current stored generation and exact expiry.
446
+ *
447
+ * @param record Current record.
448
+ * @param seal Authenticated payload.
449
+ * @returns Whether the payload still identifies this generation.
231
450
  */
232
- sweepExpired(now) {
233
- for (const [id, record] of this.records)
234
- if (this.expired(record, now))
235
- this.records.delete(id);
451
+ #matches(record, seal) {
452
+ return (record.generation === seal.generation &&
453
+ SessionRecords.compare(SessionRecords.expiry(record), seal.expires) === 0);
236
454
  }
237
455
  /**
238
- * Compares a session expiry with the current millisecond reading.
239
- * @param record The session record to inspect.
240
- * @param now The validated current time.
241
- * @returns Whether the record has expired.
456
+ * Checks exact application expiry against the trusted clock.
457
+ *
458
+ * @param expires Exact authenticated deadline.
459
+ * @returns Whether the deadline has arrived or clock failed.
242
460
  */
243
- expired(record, now) {
244
- return now >= record.expiresAt;
461
+ #expired(expires) {
462
+ const now = this.#now();
463
+ return now === undefined || SessionRecords.compare(expires, SessionPolicy.timestamp(now)) <= 0;
245
464
  }
246
465
  /**
247
- * Reads a safe clock value and closes the store on clock failure.
248
- * @returns Current epoch milliseconds, or undefined after failure.
466
+ * Reads and validates a trusted clock value.
467
+ *
468
+ * @returns Safe milliseconds, if valid.
249
469
  */
250
- now() {
470
+ #now() {
251
471
  try {
252
- const value = this.clock.now();
253
- if (!Number.isSafeInteger(value) || !OpaqueSessionValues.timestampValid(value))
254
- throw new Error("invalid clock");
472
+ const value = this.#clock.now();
473
+ if (!Number.isSafeInteger(value))
474
+ throw new Error("Invalid session clock.");
475
+ SessionRecords.deadline(SessionPolicy.timestamp(value));
255
476
  return value;
256
477
  }
257
478
  catch {
258
- this.failClosed();
479
+ this.#closed = true;
480
+ this.#lifetimes.close();
481
+ return undefined;
482
+ }
483
+ }
484
+ /**
485
+ * Reads closure after callbacks that can change lifecycle state.
486
+ *
487
+ * @returns Whether the strategy has closed.
488
+ */
489
+ #isClosed() {
490
+ return this.#closed;
491
+ }
492
+ /**
493
+ * Calculates one exact millisecond deadline without overflow.
494
+ *
495
+ * @param now Valid current milliseconds.
496
+ * @returns Exact deadline if representable.
497
+ */
498
+ #deadline(now) {
499
+ const value = now + this.#ttlMilliseconds;
500
+ if (!Number.isSafeInteger(value))
501
+ return undefined;
502
+ try {
503
+ return SessionRecords.deadline(SessionPolicy.timestamp(value));
504
+ }
505
+ catch {
506
+ return undefined;
507
+ }
508
+ }
509
+ /**
510
+ * Generates and clears cryptographic bytes for an opaque reference.
511
+ *
512
+ * @returns Base64url random value when the source succeeds.
513
+ */
514
+ #randomValue() {
515
+ let bytes;
516
+ try {
517
+ bytes = this.#random(32);
518
+ }
519
+ catch {
259
520
  return undefined;
260
521
  }
522
+ try {
523
+ return bytes.byteLength === 32 ? Buffer.from(bytes).toString("base64url") : undefined;
524
+ }
525
+ finally {
526
+ bytes.fill(0);
527
+ }
261
528
  }
262
529
  /**
263
- * Closes the store and clears session records after a clock fault.
530
+ * Copies authoritative stored identity and deadline for a caller.
531
+ *
532
+ * @param record Complete stored record.
533
+ * @returns Detached resolved session.
264
534
  */
265
- failClosed() {
266
- this.closed = true;
267
- this.records.clear();
535
+ #resolved(record) {
536
+ return Object.freeze({
537
+ principal: SessionRecords.publicPrincipal(record),
538
+ expiresAt: SessionRecords.expiry(record),
539
+ });
268
540
  }
269
541
  }
270
542
  /**
271
- * Validates opaque-session values and creates immutable credential data.
543
+ * Validates session configuration and builds exact public values.
272
544
  */
273
- const OpaqueSessionValues = Object.freeze({
545
+ const SessionPolicy = Object.freeze({
274
546
  /**
275
- * Validates a positive safe integer for a session limit.
276
- * @param value The candidate limit.
277
- * @param name The option named in an error.
278
- * @returns The accepted limit.
547
+ * Validates a positive safe policy integer.
548
+ *
549
+ * @param value Candidate number.
550
+ * @param name Option named in an error.
551
+ * @returns Accepted number.
279
552
  */
280
- positiveSafeInteger(value, name) {
553
+ positive(value, name) {
281
554
  if (!Number.isSafeInteger(value) || value <= 0)
282
- throw new Error(`${name} must be a positive safe integer`);
555
+ throw new RangeError(`${name} must be a positive safe integer.`);
283
556
  return value;
284
557
  },
285
558
  /**
286
- * Wraps an opaque identifier in an immutable cookie credential.
287
- * @param value The opaque identifier.
288
- * @returns The cookie credential.
559
+ * Validates and snapshots every configured iron-session secret.
560
+ *
561
+ * @param value Secret or numeric version map.
562
+ * @returns Detached validated configuration.
289
563
  */
290
- credential(value) {
291
- return Object.freeze({ kind: "cookie", value });
292
- },
293
- /**
294
- * Copies a principal and freezes its attributes before retention.
295
- * @param principal The authenticated identity to copy.
296
- * @returns An immutable principal snapshot.
297
- */
298
- copyPrincipal(principal) {
299
- if (typeof principal.id !== "string")
300
- throw new Error("principal.id must be a string");
301
- const attributes = principal.attributes === undefined ? undefined : Object.freeze({ ...principal.attributes });
302
- return Object.freeze(attributes === undefined ? { id: principal.id } : { id: principal.id, attributes });
303
- },
304
- /**
305
- * Builds a detached resolved session from a retained record.
306
- * @param record The live session record.
307
- * @returns The resolved principal and precise expiry.
308
- */
309
- resolved(record) {
310
- return Object.freeze({
311
- principal: OpaqueSessionValues.copyPrincipal(record.principal),
312
- expiresAt: OpaqueSessionValues.timestamp(record.expiresAt),
313
- });
564
+ password(value) {
565
+ if (typeof value === "string") {
566
+ if (value.length < 32)
567
+ throw new RangeError("Session password must have at least 32 characters.");
568
+ return value;
569
+ }
570
+ const candidate = value;
571
+ if (typeof candidate !== "object" || candidate === null || Object.keys(candidate).length === 0)
572
+ throw new TypeError("Session password map must be nonempty.");
573
+ const copy = {};
574
+ for (const [key, secret] of Object.entries(candidate)) {
575
+ if (!/^\d+$/.test(key) || !Number.isSafeInteger(Number(key)) || Number(key) <= 0)
576
+ throw new TypeError("Session password IDs must be positive numeric integers.");
577
+ if (typeof secret !== "string" || secret.length < 32)
578
+ throw new RangeError("Every session password must have at least 32 characters.");
579
+ copy[key] = secret;
580
+ }
581
+ return copy;
314
582
  },
315
583
  /**
316
- * Converts epoch milliseconds to a Protobuf expiry timestamp.
317
- * @param milliseconds The expiry time to encode.
318
- * @returns The normalized Protobuf timestamp.
584
+ * Converts safe epoch milliseconds without losing subsecond precision.
585
+ *
586
+ * @param milliseconds Safe epoch millisecond value.
587
+ * @returns Protobuf timestamp.
319
588
  */
320
589
  timestamp(milliseconds) {
321
590
  const seconds = Math.floor(milliseconds / 1_000);
@@ -325,13 +594,13 @@ const OpaqueSessionValues = Object.freeze({
325
594
  });
326
595
  },
327
596
  /**
328
- * Checks whether epoch milliseconds fit the Protobuf timestamp range.
329
- * @param milliseconds The candidate expiry time.
330
- * @returns Whether it fits the supported range.
597
+ * Wraps a sealed value in an immutable cookie credential.
598
+ *
599
+ * @param value Iron-session seal.
600
+ * @returns Cookie credential.
331
601
  */
332
- timestampValid(milliseconds) {
333
- const seconds = Math.floor(milliseconds / 1_000);
334
- return seconds >= -62_135_596_800 && seconds <= 253_402_300_799;
602
+ credential(value) {
603
+ return Object.freeze({ kind: "cookie", value });
335
604
  },
336
605
  });
337
606
  //# sourceMappingURL=opaque.js.map