@web-ts-toolkit/express-oidc-vault-memory-store 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/index.d.mts +6 -3
- package/index.d.ts +6 -3
- package/index.js +125 -4
- package/index.mjs +128 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -94,6 +94,7 @@ The store keeps these record kinds in separate in-process maps:
|
|
|
94
94
|
- rotated-session aliases, used only so an old public session ID can revoke the current logical session
|
|
95
95
|
- backchannel logout token JTIs, consumed once until their expiry time
|
|
96
96
|
- DPoP proof reservations with their own bounded expiry heap/capacity
|
|
97
|
+
- dedicated logout continuation tickets with independent capacity and removable expiry nodes
|
|
97
98
|
|
|
98
99
|
`createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; creating the same key again replaces the old value. A `createSession` replacement takes over the session ID with its own subject/logical/provider-session scope, and clears any stale rotation alias held under that ID so the reused ID cannot invoke an old logical-session meaning. Portable callers must still create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError`: reusing a live ID replaces on memory/MongoDB but rejects on Redis. MongoDB also retains an alias row under a reused ID until expiry or lineage cleanup; only fresh-ID creation is portable.
|
|
99
100
|
|
|
@@ -128,6 +129,16 @@ All shared types (`OidcVaultDeviceBindingStoreProvider`, `OidcVaultRecordBinding
|
|
|
128
129
|
|
|
129
130
|
Session rotation inherits omitted/undefined binding and accepts the same original key; it rejects null/malformed binding, a changed key, or adding a binding to an unbound source before mutation. Session IDs still follow the upsert/alias contracts above. A revocation alias never supplies historical or missing binding as proof of unboundness: all live lineage members must agree on binding/provider identity (malformed/inconsistent authority throws a fixed private diagnostic). Revocation context scans that lineage's candidates in the session map; it does not authenticate aliases through `getSession`.
|
|
130
131
|
|
|
132
|
+
## Dedicated Logout Continuation Tickets
|
|
133
|
+
|
|
134
|
+
The inferred factory result satisfies both core `OidcVaultDeviceBindingStoreProvider` and `OidcVaultLogoutNavigationStoreProvider`. The latter requires `createLogoutNavigationTicket` / `consumeLogoutNavigationTicket`; they remain optional on the base custom-store contract. Import ticket/create/consume types and `OidcVaultLogoutNavigationCapacityError` from the **core root**. This storage extension does not enable HTTP routes.
|
|
135
|
+
|
|
136
|
+
`logoutNavigationMaxEntries` is a positive safe integer, default **10000**, shared across effective ticket namespaces in this object, independent of replay capacity. Live collisions throw `OidcVaultStoreConflictError` before capacity rejection, preserving destination/expiry. New admissions at capacity throw `OidcVaultLogoutNavigationCapacityError`, never evicting live grants. Exactly five scalar fields are captured before yielding and returned detached: `namespace`, `ticketHash`, `upstreamLogoutUrl`, `createdAt`, `expiresAt`. Invalid creates throw TypeError; invalid/unknown/spent/expired/malformed selected consumes return null and retire selected unusable data. Clock failures throw. Core's README defines canonical scope/hash formats, 256-bit generation, Date-range integer clocks/epochs, 1000..120000-ms original and maximum remaining lifetime, strict expiry, and 16384-byte HTTP(S) URL syntax.
|
|
137
|
+
|
|
138
|
+
A dedicated map and **removable indexed min-heap** retain one node per ticket. Check/remove has no asynchronous gap; arbitrary consumes remove their heap node immediately, without tombstones. Each ticket operation removes its selected entry plus at most **64 unrelated expiries**, with O(log N) heap work and no session/alias/replay sweeps. Unknown consumes allocate no entries. Expired payloads can remain idle until later ticket traffic or disposal, but are unusable and occupy the fixed cap. No timers. Only this same memory object shares consumption; separate objects/processes do not.
|
|
139
|
+
|
|
140
|
+
The already-built URL can retain an ID-token hint; hashing does not encrypt it. No raw ticket/session/profile/separate tokens are stored. Exclude records/URLs from diagnostics. Session/lineage/backchannel deletion does not cascade to authorized tickets; discarding the store loses them. Existing identity, alias, rotation and replay contracts remain intact.
|
|
141
|
+
|
|
131
142
|
## Per-Request DPoP Replay And Capacity
|
|
132
143
|
|
|
133
144
|
| Memory option | Default | Contract |
|
package/index.d.mts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
1
|
+
import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
2
2
|
|
|
3
3
|
/** Construction options for the named package-root memory-store factory. */
|
|
4
4
|
interface MemoryOidcVaultStoreOptions {
|
|
5
|
+
/** Independent physical-store ticket cap, shared across namespaces; default 10000. No live eviction. */
|
|
6
|
+
logoutNavigationMaxEntries?: number;
|
|
5
7
|
/** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
|
|
6
8
|
dpopReplayMaxEntries?: number;
|
|
7
9
|
/**
|
|
@@ -20,10 +22,11 @@ interface MemoryOidcVaultStoreOptions {
|
|
|
20
22
|
* during store operations, and lost when the Node.js process exits. Do not use
|
|
21
23
|
* this provider when sessions must survive restarts or be shared by multiple
|
|
22
24
|
* application instances. The returned stronger provider includes guarded
|
|
23
|
-
* exact/null consumes, token-free revocation context,
|
|
25
|
+
* exact/null consumes, token-free revocation context, bounded DPoP replay and
|
|
26
|
+
* independent, atomic logout continuation tickets (default cap 10000).
|
|
24
27
|
* Replay is shared only by callers reusing this same object; the store does not
|
|
25
28
|
* verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
|
|
26
29
|
*/
|
|
27
|
-
declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
|
|
30
|
+
declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
|
|
28
31
|
|
|
29
32
|
export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
1
|
+
import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
2
2
|
|
|
3
3
|
/** Construction options for the named package-root memory-store factory. */
|
|
4
4
|
interface MemoryOidcVaultStoreOptions {
|
|
5
|
+
/** Independent physical-store ticket cap, shared across namespaces; default 10000. No live eviction. */
|
|
6
|
+
logoutNavigationMaxEntries?: number;
|
|
5
7
|
/** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
|
|
6
8
|
dpopReplayMaxEntries?: number;
|
|
7
9
|
/**
|
|
@@ -20,10 +22,11 @@ interface MemoryOidcVaultStoreOptions {
|
|
|
20
22
|
* during store operations, and lost when the Node.js process exits. Do not use
|
|
21
23
|
* this provider when sessions must survive restarts or be shared by multiple
|
|
22
24
|
* application instances. The returned stronger provider includes guarded
|
|
23
|
-
* exact/null consumes, token-free revocation context,
|
|
25
|
+
* exact/null consumes, token-free revocation context, bounded DPoP replay and
|
|
26
|
+
* independent, atomic logout continuation tickets (default cap 10000).
|
|
24
27
|
* Replay is shared only by callers reusing this same object; the store does not
|
|
25
28
|
* verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
|
|
26
29
|
*/
|
|
27
|
-
declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
|
|
30
|
+
declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
|
|
28
31
|
|
|
29
32
|
export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
|
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
createMemoryOidcVaultStore: () => createMemoryOidcVaultStore
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(index_exports);
|
|
26
|
-
var
|
|
26
|
+
var import_express_oidc_vault4 = require("@web-ts-toolkit/express-oidc-vault");
|
|
27
27
|
|
|
28
28
|
// src/binding.ts
|
|
29
29
|
var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
|
|
@@ -138,6 +138,117 @@ var DpopReplayReservations = class {
|
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
// src/logout-tickets.ts
|
|
142
|
+
var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
|
|
143
|
+
|
|
144
|
+
// src/logout-ticket-validation.ts
|
|
145
|
+
var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
|
146
|
+
var PREFIX = "logout-navigation:v1:";
|
|
147
|
+
var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
|
|
148
|
+
var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
|
|
149
|
+
var ticketNow = (now) => {
|
|
150
|
+
if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
|
|
151
|
+
return now;
|
|
152
|
+
};
|
|
153
|
+
var captureTicketIdentity = (input) => ({
|
|
154
|
+
namespace: input?.namespace,
|
|
155
|
+
ticketHash: input?.ticketHash
|
|
156
|
+
});
|
|
157
|
+
var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
|
|
158
|
+
var captureTicket = (input) => ({
|
|
159
|
+
...captureTicketIdentity(input),
|
|
160
|
+
upstreamLogoutUrl: input?.upstreamLogoutUrl,
|
|
161
|
+
createdAt: input?.createdAt,
|
|
162
|
+
expiresAt: input?.expiresAt
|
|
163
|
+
});
|
|
164
|
+
var validTicket = (record, now) => {
|
|
165
|
+
if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
|
|
166
|
+
return false;
|
|
167
|
+
try {
|
|
168
|
+
const url = new URL(record.upstreamLogoutUrl);
|
|
169
|
+
return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var assertTicket = (record, now) => {
|
|
175
|
+
if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
|
|
176
|
+
};
|
|
177
|
+
var ticketLimit = (value) => {
|
|
178
|
+
const max = value === void 0 ? 1e4 : value;
|
|
179
|
+
if (!Number.isSafeInteger(max) || max <= 0)
|
|
180
|
+
throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
|
|
181
|
+
return max;
|
|
182
|
+
};
|
|
183
|
+
var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
|
|
184
|
+
|
|
185
|
+
// src/logout-tickets.ts
|
|
186
|
+
var LogoutNavigationTickets = class {
|
|
187
|
+
constructor(maxEntries) {
|
|
188
|
+
this.maxEntries = maxEntries;
|
|
189
|
+
this.tickets = /* @__PURE__ */ new Map();
|
|
190
|
+
this.expiryHeap = [];
|
|
191
|
+
}
|
|
192
|
+
create(record, now) {
|
|
193
|
+
ticketNow(now);
|
|
194
|
+
assertTicket(record, now);
|
|
195
|
+
const id = ticketId(record);
|
|
196
|
+
const existing = this.tickets.get(id);
|
|
197
|
+
if (existing && validTicket(existing.record, now)) throw new import_express_oidc_vault3.OidcVaultStoreConflictError();
|
|
198
|
+
if (existing) this.remove(existing.index);
|
|
199
|
+
this.cleanup(now);
|
|
200
|
+
if (this.tickets.size >= this.maxEntries) throw new import_express_oidc_vault3.OidcVaultLogoutNavigationCapacityError();
|
|
201
|
+
const entry = { id, record, index: this.expiryHeap.length };
|
|
202
|
+
this.tickets.set(id, entry);
|
|
203
|
+
this.expiryHeap.push(entry);
|
|
204
|
+
this.moveUp(entry.index);
|
|
205
|
+
}
|
|
206
|
+
consume(input, now) {
|
|
207
|
+
ticketNow(now);
|
|
208
|
+
const selected = this.tickets.get(ticketId(input));
|
|
209
|
+
if (selected) this.remove(selected.index);
|
|
210
|
+
this.cleanup(now);
|
|
211
|
+
return selected && validTicket(selected.record, now) && ticketId(selected.record) === ticketId(input) ? { ...selected.record } : null;
|
|
212
|
+
}
|
|
213
|
+
cleanup(now) {
|
|
214
|
+
for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.record.expiresAt <= now; cleaned += 1) this.remove(0);
|
|
215
|
+
}
|
|
216
|
+
remove(index) {
|
|
217
|
+
const removed = this.expiryHeap[index];
|
|
218
|
+
const last = this.expiryHeap.pop();
|
|
219
|
+
this.tickets.delete(removed.id);
|
|
220
|
+
if (index === this.expiryHeap.length) return;
|
|
221
|
+
this.expiryHeap[index] = last;
|
|
222
|
+
last.index = index;
|
|
223
|
+
this.moveUp(index);
|
|
224
|
+
this.moveDown(last.index);
|
|
225
|
+
}
|
|
226
|
+
swap(left, right) {
|
|
227
|
+
[this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
|
|
228
|
+
this.expiryHeap[left].index = left;
|
|
229
|
+
this.expiryHeap[right].index = right;
|
|
230
|
+
}
|
|
231
|
+
moveUp(index) {
|
|
232
|
+
while (index > 0) {
|
|
233
|
+
const parent = Math.floor((index - 1) / 2);
|
|
234
|
+
if (this.expiryHeap[parent].record.expiresAt <= this.expiryHeap[index].record.expiresAt) break;
|
|
235
|
+
this.swap(parent, index);
|
|
236
|
+
index = parent;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
moveDown(index) {
|
|
240
|
+
for (; ; ) {
|
|
241
|
+
const left = index * 2 + 1;
|
|
242
|
+
if (left >= this.expiryHeap.length) return;
|
|
243
|
+
const right = left + 1;
|
|
244
|
+
const child = right < this.expiryHeap.length && this.expiryHeap[right].record.expiresAt < this.expiryHeap[left].record.expiresAt ? right : left;
|
|
245
|
+
if (this.expiryHeap[index].record.expiresAt <= this.expiryHeap[child].record.expiresAt) return;
|
|
246
|
+
this.swap(index, child);
|
|
247
|
+
index = child;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
141
252
|
// src/index.ts
|
|
142
253
|
var cloneRecord = (value) => structuredClone(value);
|
|
143
254
|
var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
|
|
@@ -180,6 +291,7 @@ var MemoryOidcVaultStore = class {
|
|
|
180
291
|
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
181
292
|
}
|
|
182
293
|
this.dpopProofs = new DpopReplayReservations(maxEntries);
|
|
294
|
+
this.logoutTickets = new LogoutNavigationTickets(ticketLimit(options.logoutNavigationMaxEntries));
|
|
183
295
|
this.now = options.now ?? (() => Date.now());
|
|
184
296
|
}
|
|
185
297
|
async createAuthorizationTransaction(input) {
|
|
@@ -310,6 +422,15 @@ var MemoryOidcVaultStore = class {
|
|
|
310
422
|
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
311
423
|
return this.dpopProofs.reserve(input, this.now());
|
|
312
424
|
}
|
|
425
|
+
async createLogoutNavigationTicket(input) {
|
|
426
|
+
const record = captureTicket(input);
|
|
427
|
+
this.logoutTickets.create(record, this.now());
|
|
428
|
+
}
|
|
429
|
+
async consumeLogoutNavigationTicket(input) {
|
|
430
|
+
const identity = captureTicketIdentity(input);
|
|
431
|
+
if (!validTicketIdentity(identity)) return null;
|
|
432
|
+
return this.logoutTickets.consume(identity, this.now());
|
|
433
|
+
}
|
|
313
434
|
async rotateSession(input) {
|
|
314
435
|
const timestamp = this.now();
|
|
315
436
|
const sourceSession = this.sessions.get(input.sessionId);
|
|
@@ -319,14 +440,14 @@ var MemoryOidcVaultStore = class {
|
|
|
319
440
|
}
|
|
320
441
|
const liveSourceSession = this.sessions.get(input.sessionId);
|
|
321
442
|
if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
|
|
322
|
-
throw new
|
|
443
|
+
throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
323
444
|
}
|
|
324
445
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
325
|
-
throw new
|
|
446
|
+
throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
326
447
|
}
|
|
327
448
|
const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
|
|
328
449
|
if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
|
|
329
|
-
throw new
|
|
450
|
+
throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
330
451
|
}
|
|
331
452
|
const nextSession = cloneRecord(input.nextSession);
|
|
332
453
|
inheritSessionBinding(liveSourceSession, nextSession);
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
-
OidcVaultStoreConflictError as
|
|
3
|
+
OidcVaultStoreConflictError as OidcVaultStoreConflictError3
|
|
4
4
|
} from "@web-ts-toolkit/express-oidc-vault";
|
|
5
5
|
|
|
6
6
|
// src/binding.ts
|
|
@@ -116,6 +116,120 @@ var DpopReplayReservations = class {
|
|
|
116
116
|
}
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
+
// src/logout-tickets.ts
|
|
120
|
+
import {
|
|
121
|
+
OidcVaultLogoutNavigationCapacityError,
|
|
122
|
+
OidcVaultStoreConflictError as OidcVaultStoreConflictError2
|
|
123
|
+
} from "@web-ts-toolkit/express-oidc-vault";
|
|
124
|
+
|
|
125
|
+
// src/logout-ticket-validation.ts
|
|
126
|
+
var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
|
127
|
+
var PREFIX = "logout-navigation:v1:";
|
|
128
|
+
var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
|
|
129
|
+
var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
|
|
130
|
+
var ticketNow = (now) => {
|
|
131
|
+
if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
|
|
132
|
+
return now;
|
|
133
|
+
};
|
|
134
|
+
var captureTicketIdentity = (input) => ({
|
|
135
|
+
namespace: input?.namespace,
|
|
136
|
+
ticketHash: input?.ticketHash
|
|
137
|
+
});
|
|
138
|
+
var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
|
|
139
|
+
var captureTicket = (input) => ({
|
|
140
|
+
...captureTicketIdentity(input),
|
|
141
|
+
upstreamLogoutUrl: input?.upstreamLogoutUrl,
|
|
142
|
+
createdAt: input?.createdAt,
|
|
143
|
+
expiresAt: input?.expiresAt
|
|
144
|
+
});
|
|
145
|
+
var validTicket = (record, now) => {
|
|
146
|
+
if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
|
|
147
|
+
return false;
|
|
148
|
+
try {
|
|
149
|
+
const url = new URL(record.upstreamLogoutUrl);
|
|
150
|
+
return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
var assertTicket = (record, now) => {
|
|
156
|
+
if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
|
|
157
|
+
};
|
|
158
|
+
var ticketLimit = (value) => {
|
|
159
|
+
const max = value === void 0 ? 1e4 : value;
|
|
160
|
+
if (!Number.isSafeInteger(max) || max <= 0)
|
|
161
|
+
throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
|
|
162
|
+
return max;
|
|
163
|
+
};
|
|
164
|
+
var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
|
|
165
|
+
|
|
166
|
+
// src/logout-tickets.ts
|
|
167
|
+
var LogoutNavigationTickets = class {
|
|
168
|
+
constructor(maxEntries) {
|
|
169
|
+
this.maxEntries = maxEntries;
|
|
170
|
+
this.tickets = /* @__PURE__ */ new Map();
|
|
171
|
+
this.expiryHeap = [];
|
|
172
|
+
}
|
|
173
|
+
create(record, now) {
|
|
174
|
+
ticketNow(now);
|
|
175
|
+
assertTicket(record, now);
|
|
176
|
+
const id = ticketId(record);
|
|
177
|
+
const existing = this.tickets.get(id);
|
|
178
|
+
if (existing && validTicket(existing.record, now)) throw new OidcVaultStoreConflictError2();
|
|
179
|
+
if (existing) this.remove(existing.index);
|
|
180
|
+
this.cleanup(now);
|
|
181
|
+
if (this.tickets.size >= this.maxEntries) throw new OidcVaultLogoutNavigationCapacityError();
|
|
182
|
+
const entry = { id, record, index: this.expiryHeap.length };
|
|
183
|
+
this.tickets.set(id, entry);
|
|
184
|
+
this.expiryHeap.push(entry);
|
|
185
|
+
this.moveUp(entry.index);
|
|
186
|
+
}
|
|
187
|
+
consume(input, now) {
|
|
188
|
+
ticketNow(now);
|
|
189
|
+
const selected = this.tickets.get(ticketId(input));
|
|
190
|
+
if (selected) this.remove(selected.index);
|
|
191
|
+
this.cleanup(now);
|
|
192
|
+
return selected && validTicket(selected.record, now) && ticketId(selected.record) === ticketId(input) ? { ...selected.record } : null;
|
|
193
|
+
}
|
|
194
|
+
cleanup(now) {
|
|
195
|
+
for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.record.expiresAt <= now; cleaned += 1) this.remove(0);
|
|
196
|
+
}
|
|
197
|
+
remove(index) {
|
|
198
|
+
const removed = this.expiryHeap[index];
|
|
199
|
+
const last = this.expiryHeap.pop();
|
|
200
|
+
this.tickets.delete(removed.id);
|
|
201
|
+
if (index === this.expiryHeap.length) return;
|
|
202
|
+
this.expiryHeap[index] = last;
|
|
203
|
+
last.index = index;
|
|
204
|
+
this.moveUp(index);
|
|
205
|
+
this.moveDown(last.index);
|
|
206
|
+
}
|
|
207
|
+
swap(left, right) {
|
|
208
|
+
[this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
|
|
209
|
+
this.expiryHeap[left].index = left;
|
|
210
|
+
this.expiryHeap[right].index = right;
|
|
211
|
+
}
|
|
212
|
+
moveUp(index) {
|
|
213
|
+
while (index > 0) {
|
|
214
|
+
const parent = Math.floor((index - 1) / 2);
|
|
215
|
+
if (this.expiryHeap[parent].record.expiresAt <= this.expiryHeap[index].record.expiresAt) break;
|
|
216
|
+
this.swap(parent, index);
|
|
217
|
+
index = parent;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
moveDown(index) {
|
|
221
|
+
for (; ; ) {
|
|
222
|
+
const left = index * 2 + 1;
|
|
223
|
+
if (left >= this.expiryHeap.length) return;
|
|
224
|
+
const right = left + 1;
|
|
225
|
+
const child = right < this.expiryHeap.length && this.expiryHeap[right].record.expiresAt < this.expiryHeap[left].record.expiresAt ? right : left;
|
|
226
|
+
if (this.expiryHeap[index].record.expiresAt <= this.expiryHeap[child].record.expiresAt) return;
|
|
227
|
+
this.swap(index, child);
|
|
228
|
+
index = child;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
119
233
|
// src/index.ts
|
|
120
234
|
var cloneRecord = (value) => structuredClone(value);
|
|
121
235
|
var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
|
|
@@ -158,6 +272,7 @@ var MemoryOidcVaultStore = class {
|
|
|
158
272
|
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
159
273
|
}
|
|
160
274
|
this.dpopProofs = new DpopReplayReservations(maxEntries);
|
|
275
|
+
this.logoutTickets = new LogoutNavigationTickets(ticketLimit(options.logoutNavigationMaxEntries));
|
|
161
276
|
this.now = options.now ?? (() => Date.now());
|
|
162
277
|
}
|
|
163
278
|
async createAuthorizationTransaction(input) {
|
|
@@ -288,6 +403,15 @@ var MemoryOidcVaultStore = class {
|
|
|
288
403
|
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
289
404
|
return this.dpopProofs.reserve(input, this.now());
|
|
290
405
|
}
|
|
406
|
+
async createLogoutNavigationTicket(input) {
|
|
407
|
+
const record = captureTicket(input);
|
|
408
|
+
this.logoutTickets.create(record, this.now());
|
|
409
|
+
}
|
|
410
|
+
async consumeLogoutNavigationTicket(input) {
|
|
411
|
+
const identity = captureTicketIdentity(input);
|
|
412
|
+
if (!validTicketIdentity(identity)) return null;
|
|
413
|
+
return this.logoutTickets.consume(identity, this.now());
|
|
414
|
+
}
|
|
291
415
|
async rotateSession(input) {
|
|
292
416
|
const timestamp = this.now();
|
|
293
417
|
const sourceSession = this.sessions.get(input.sessionId);
|
|
@@ -297,14 +421,14 @@ var MemoryOidcVaultStore = class {
|
|
|
297
421
|
}
|
|
298
422
|
const liveSourceSession = this.sessions.get(input.sessionId);
|
|
299
423
|
if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
|
|
300
|
-
throw new
|
|
424
|
+
throw new OidcVaultStoreConflictError3("OIDC vault session no longer exists for rotation.");
|
|
301
425
|
}
|
|
302
426
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
303
|
-
throw new
|
|
427
|
+
throw new OidcVaultStoreConflictError3("OIDC vault session rotation target must use a different session ID.");
|
|
304
428
|
}
|
|
305
429
|
const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
|
|
306
430
|
if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
|
|
307
|
-
throw new
|
|
431
|
+
throw new OidcVaultStoreConflictError3("OIDC vault session rotation target already exists.");
|
|
308
432
|
}
|
|
309
433
|
const nextSession = cloneRecord(input.nextSession);
|
|
310
434
|
inheritSessionBinding(liveSourceSession, nextSession);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/express-oidc-vault-memory-store",
|
|
3
3
|
"description": "In-memory store provider for @web-ts-toolkit/express-oidc-vault",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-memory-store",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.54.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"express",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22.12.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@web-ts-toolkit/express-oidc-vault": "0.
|
|
32
|
+
"@web-ts-toolkit/express-oidc-vault": "0.54.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"**/*",
|