@spine-event-engine/auth 2.0.0-snapshot.26 → 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.
- package/README.md +102 -11
- package/REFERENCE.md +125 -3
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/oidc/contracts.d.ts +18 -4
- package/dist/oidc/contracts.d.ts.map +1 -1
- package/dist/oidc/flow-store.d.ts +143 -0
- package/dist/oidc/flow-store.d.ts.map +1 -0
- package/dist/oidc/flow-store.js +184 -0
- package/dist/oidc/flow-store.js.map +1 -0
- package/dist/oidc/index.d.ts +29 -9
- package/dist/oidc/index.d.ts.map +1 -1
- package/dist/oidc/index.js +611 -152
- package/dist/oidc/index.js.map +1 -1
- package/dist/oidc/persistent-flow-store.d.ts +117 -0
- package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
- package/dist/oidc/persistent-flow-store.js +413 -0
- package/dist/oidc/persistent-flow-store.js.map +1 -0
- package/dist/oidc/records.d.ts +15 -0
- package/dist/oidc/records.d.ts.map +1 -0
- package/dist/oidc/records.js +91 -0
- package/dist/oidc/records.js.map +1 -0
- package/dist/providers/index.d.ts +10 -5
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +103 -30
- package/dist/providers/index.js.map +1 -1
- package/dist/sessions/cookies.d.ts +46 -8
- package/dist/sessions/cookies.d.ts.map +1 -1
- package/dist/sessions/cookies.js +269 -47
- package/dist/sessions/cookies.js.map +1 -1
- package/dist/sessions/opaque-session-store.d.ts +143 -0
- package/dist/sessions/opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/opaque-session-store.js +185 -0
- package/dist/sessions/opaque-session-store.js.map +1 -0
- package/dist/sessions/opaque.d.ts +93 -106
- package/dist/sessions/opaque.d.ts.map +1 -1
- package/dist/sessions/opaque.js +501 -232
- package/dist/sessions/opaque.js.map +1 -1
- package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
- package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/persistent-opaque-session-store.js +333 -0
- package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
- package/dist/sessions/persistent-token-revocations.d.ts +90 -0
- package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
- package/dist/sessions/persistent-token-revocations.js +422 -0
- package/dist/sessions/persistent-token-revocations.js.map +1 -0
- package/dist/sessions/session-lifetime.d.ts +67 -0
- package/dist/sessions/session-lifetime.d.ts.map +1 -0
- package/dist/sessions/session-lifetime.js +165 -0
- package/dist/sessions/session-lifetime.js.map +1 -0
- package/dist/sessions/session-record.d.ts +15 -0
- package/dist/sessions/session-record.d.ts.map +1 -0
- package/dist/sessions/session-record.js +109 -0
- package/dist/sessions/session-record.js.map +1 -0
- package/dist/sessions/signed.d.ts +5 -4
- package/dist/sessions/signed.d.ts.map +1 -1
- package/dist/sessions/signed.js +212 -74
- package/dist/sessions/signed.js.map +1 -1
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +260 -26
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -6
package/dist/sessions/opaque.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
38
|
-
this
|
|
39
|
-
this
|
|
40
|
-
this
|
|
41
|
-
this
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* @
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @
|
|
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
|
-
|
|
89
|
-
if (this
|
|
90
|
-
return Promise.resolve(
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* @
|
|
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
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
return
|
|
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
|
|
157
|
-
*
|
|
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
|
|
161
|
-
return
|
|
162
|
-
this
|
|
163
|
-
this.
|
|
164
|
-
|
|
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
|
-
*
|
|
168
|
-
*
|
|
206
|
+
* Waits for admitted work and closes only internally created memory.
|
|
207
|
+
*
|
|
208
|
+
* @returns Completion after local resources are released.
|
|
169
209
|
*/
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* @
|
|
177
|
-
* @
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
*
|
|
189
|
-
*
|
|
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
|
-
|
|
192
|
-
for (let attempt = 0; attempt < this
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* @param
|
|
217
|
-
* @
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* @param
|
|
241
|
-
* @returns Whether the
|
|
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(
|
|
244
|
-
|
|
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
|
|
248
|
-
*
|
|
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
|
|
253
|
-
if (!Number.isSafeInteger(value)
|
|
254
|
-
throw new Error("
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
543
|
+
* Validates session configuration and builds exact public values.
|
|
272
544
|
*/
|
|
273
|
-
const
|
|
545
|
+
const SessionPolicy = Object.freeze({
|
|
274
546
|
/**
|
|
275
|
-
* Validates a positive safe integer
|
|
276
|
-
*
|
|
277
|
-
* @param
|
|
278
|
-
* @
|
|
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
|
-
|
|
553
|
+
positive(value, name) {
|
|
281
554
|
if (!Number.isSafeInteger(value) || value <= 0)
|
|
282
|
-
throw new
|
|
555
|
+
throw new RangeError(`${name} must be a positive safe integer.`);
|
|
283
556
|
return value;
|
|
284
557
|
},
|
|
285
558
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* @
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
317
|
-
*
|
|
318
|
-
* @
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* @
|
|
597
|
+
* Wraps a sealed value in an immutable cookie credential.
|
|
598
|
+
*
|
|
599
|
+
* @param value Iron-session seal.
|
|
600
|
+
* @returns Cookie credential.
|
|
331
601
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
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
|