@web-ts-toolkit/express-oidc-vault-mongodb-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 CHANGED
@@ -74,7 +74,7 @@ process.once('SIGTERM', async () => {
74
74
 
75
75
  ## Collection Names
76
76
 
77
- The provider uses seven separate MongoDB collections by default:
77
+ The provider uses nine separate MongoDB collections by default:
78
78
 
79
79
  - `oidc_vault_authorization_transactions`: authorization transaction state, nonce, PKCE verifier, and metadata
80
80
  - `oidc_vault_exchange_codes`: short-lived frontend exchange codes
@@ -83,6 +83,8 @@ The provider uses seven separate MongoDB collections by default:
83
83
  - `oidc_vault_rotated_session_aliases`: stale rotated session IDs mapped to active logical sessions
84
84
  - `oidc_vault_dpop_proofs`: unique DPoP proof IDs with a TTL expiry index
85
85
  - `oidc_vault_dpop_replay_capacity`: shared capacity/serialization row and indexed, non-TTL expiry-accounting rows
86
+ - `oidc_vault_logout_navigation_tickets`: dedicated continuation URL payloads with TTL expiry and a fixed pairing row
87
+ - `oidc_vault_logout_navigation_capacity`: separate serialized ticket capacity and non-TTL expiry ledger
86
88
 
87
89
  Override collection names when your deployment needs explicit naming:
88
90
 
@@ -96,13 +98,15 @@ const storeProvider = createMongoOidcVaultStore({
96
98
  rotatedSessionAliasesCollectionName: 'auth_oidc_rotated_session_aliases',
97
99
  dpopProofsCollectionName: 'auth_dpop_proofs',
98
100
  dpopReplayCapacityCollectionName: 'auth_dpop_capacity',
101
+ logoutNavigationTicketsCollectionName: 'auth_logout_tickets',
102
+ logoutNavigationCapacityCollectionName: 'auth_logout_capacity',
99
103
  });
100
104
  ```
101
105
 
102
106
  ## Startup And Client Ownership
103
107
 
104
108
  - use MongoDB when your team already standardizes on Mongo and you want the auth vault data in the same operational platform
105
- - the provider uses seven separate collections: transactions, exchange codes, sessions, backchannel JTIs, aliases, DPoP proofs and replay capacity/accounting
109
+ - the provider uses nine separate collections: transactions, exchange codes, sessions, backchannel JTIs, aliases, DPoP proofs, replay capacity/accounting, logout tickets and ticket capacity/accounting
106
110
  - TTL indexes are created on expiring records; sessions, authorization transactions, exchange codes, backchannel logout token JTIs, and rotated-session aliases are also checked for expiration during relevant reads or consumes (`expiresAt <= now()` is expired), so behavior does not depend only on Mongo's background TTL monitor timing
107
111
  - session rotation, coherent revocation-context reads, replay-capacity admission and inactive-lineage alias cleanup require MongoDB transactions; use a replica set or sharded deployment because standalone servers fail readiness instead of using non-atomic fallbacks
108
112
  - startup readiness creates required indexes and verifies transaction-capable topology; connect the MongoDB client, create the store, await `storeProvider.ready()`, then accept traffic
@@ -111,6 +115,34 @@ const storeProvider = createMongoOidcVaultStore({
111
115
 
112
116
  Drain HTTP requests and any other in-flight store operations before closing the caller-owned `MongoClient`. The store has no shutdown method or background cleanup timer. Its explicit transaction sessions are ended in `finally` on success and failure; MongoDB's TTL monitor belongs to the server. Shared state and durability depend on your deployment, replication and backup configuration.
113
117
 
118
+ ## Primary Reads And Migration
119
+
120
+ The store selects **primary** for its own collection reads, including authorization/exchange/session preflight, alias/liveness queries, and readiness index/configuration reads. Rotation, revocation-context, inactive-alias cleanup, and replay transactions also explicitly use primary. This overrides `secondaryPreferred`, `secondary`, or other read preferences inherited from a URI, `MongoClient`, or `Db` for vault work only; there is no secondary-read exception for authentication state.
121
+
122
+ Reuse an application connection even when ordinary application reads prefer secondaries:
123
+
124
+ ```ts
125
+ import { MongoClient } from 'mongodb';
126
+ import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store';
127
+
128
+ const client = new MongoClient(process.env.MONGODB_URI!, { readPreference: 'secondaryPreferred' });
129
+ await client.connect();
130
+ const db = client.db('app-auth');
131
+ const storeProvider = createMongoOidcVaultStore({ db });
132
+ await storeProvider.ready();
133
+
134
+ // Vault reads/transactions use primary; unrelated handles keep caller defaults.
135
+ const applicationRecords = db.collection('application_records');
136
+ await applicationRecords.findOne({});
137
+
138
+ // After draining requests and other in-flight store work:
139
+ await client.close();
140
+ ```
141
+
142
+ Migration: applications with a separate primary-only vault client can instead pass their existing connected `Db`, preserving the same database and configured collection names. No second client, replacement `Db`, or per-operation application override is needed. The store leaves caller/client/Db defaults, read-preference tags/staleness settings, unrelated collection routing, BSON serialization options, and existing read/write concerns intact. The caller still owns connection, shutdown, and pool/selection/timeout configuration.
143
+
144
+ Compatibility: vault reads that previously inherited secondary routing now require a primary. If none is available, readiness or the affected operation rejects with the backend/driver error under the caller's selection/timeout policy; the store never falls back to a secondary or memory. Deletion errors can still follow committed session deletion, as described below. Primary selection is a routing policy: it does **not** add linearizable read concern, causal sessions across separate store calls, stronger write concern, or failover durability. Existing snapshot transaction concerns and rotation's inherited concerns are unchanged. Configure durability for your deployment; replica-set coverage does not certify sharded topology or distributed-failure behavior.
145
+
114
146
  ## Rotation Aliases And Lifetime
115
147
 
116
148
  - rotation requires a distinct unused target session ID; missing-source, same-ID, existing-target, changed-source, and expired-source rotation conflicts throw `OidcVaultStoreConflictError` without changing source or target records (the source document is re-read inside the transaction and must still match the generation seen before the transaction, including liveness, so a same-ID replacement committed after the rotation's source read aborts instead of being consumed)
@@ -192,6 +224,24 @@ Migration detail: the pre-binding Mongo mapper wrote BSON null for omitted **pro
192
224
 
193
225
  `getSessionRevocationContext(sessionId)` resolves a live handle or unexpired alias to a currently live lineage using one snapshot transaction. It projects only logical ID/subject/provider/binding/expiry (no refresh/ID/access tokens fetched) and returns only `logicalSessionId`, allowlisted `provider.issuer/clientId`, and optional original `deviceBinding`. No profile/metadata/arbitrary provider fields are returned. Aliases remain token/binding-free; they resolve surviving session authority rather than treating missing alias binding as unbound. Malformed/inconsistent lineage binding/provider authority throws a fixed private diagnostic. Expired aliases/empty lineages return `null`; `getSession(alias)` remains `null`. Lineage queries materialize their surviving candidates; this is not bounded replay-admission work.
194
226
 
227
+ ## Dedicated Logout Continuation Tickets
228
+
229
+ `OidcVaultMongoStoreProvider` also satisfies core `OidcVaultLogoutNavigationStoreProvider`, with required `createLogoutNavigationTicket` / `consumeLogoutNavigationTicket` and retained `ready()`. Ticket/create/consume types and `OidcVaultLogoutNavigationCapacityError` are core-root exports; both methods remain optional on the base custom-store contract. This storage extension does not register HTTP routes.
230
+
231
+ | Mongo ticket option | Default | Contract |
232
+ | ---------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------- |
233
+ | `logoutNavigationMaxEntries` | `10000` | Positive safe integer, identical on shared clients; independent of replay |
234
+ | `logoutNavigationTicketsCollectionName` | `oidc_vault_logout_navigation_tickets` | Composite namespace/hash `_id`, five-field payload with Date expiry, `expiresAt_ttl` |
235
+ | `logoutNavigationCapacityCollectionName` | `oidc_vault_logout_navigation_capacity` | Non-TTL capacity/revision and hash/expiry ledger; `logout_navigation_expiry_accounting_idx` |
236
+
237
+ All **nine** collection roles must be distinct. Readiness rejects TTL indexes on the ticket ledger and validates limits/pairing in **both directions**: a fixed configuration row in the payload collection and a fixed capacity row in the ledger. Both new handles/readiness reads use primary on the supplied Db; **every ticket transaction explicitly uses primary and snapshot read concern**. Caller defaults, concerns and client lifecycle are retained. No secondary/memory fallback.
238
+
239
+ Create/consume serialize on an actual capacity-row revision write. Transactions validate count/ledger consistency, retire the selected entry plus at most **64 unrelated indexed expiries**, and commit payload/ledger/count changes together. The consistency count is proportional to retained accounting, bounded by configured capacity; the single row can contend. Cleanup/retries add work; no throughput SLA. Successful consume frees its count once. A live ledger protects an identity from collision reuse after early payload disappearance; consume retires it and returns null. Physical TTL deletion never loses accounting. Expired payloads awaiting TTL are unusable; non-TTL accounting may occupy capacity while idle until later traffic. Normal retention is at most the cap in payloads/ledger entries plus two fixed configuration rows. Changing a collection or limit without a matching migration fails closed.
240
+
241
+ Exactly five scalar fields are captured before the first await and returned detached: `namespace`, `ticketHash`, `upstreamLogoutUrl`, `createdAt`, `expiresAt`. Core's README defines canonical scope/hash formats, 256-bit ticket generation, Date-range integer epochs/clocks, original TTL 1000..120000 ms, remaining TTL >0 and <=120000 ms, and 16384-byte HTTP(S) URL syntax. Invalid creates throw TypeError; live collisions throw `OidcVaultStoreConflictError` before capacity rejection without replacement/extension. Capacity throws `OidcVaultLogoutNavigationCapacityError`, never evicting live grants. Invalid/unknown/spent/expired/malformed selected consumes return null; selected unusable payloads are retired. Backend, clock, pairing and accounting failures throw; transaction failures roll back. Ambiguous acknowledgements are not retry authority.
242
+
243
+ The final URL can retain an ID-token hint; hashing does not encrypt it. No raw ticket, full session/profile, handle/subject or separate tokens are stored. Exclude records/credential URLs and arbitrary errors from logs. Session/lineage/backchannel deletion does not cascade to authorized tickets. Provisioning is additive; existing identity, rotation, alias and replay semantics remain intact.
244
+
195
245
  ## Per-Request DPoP Replay, Capacity And TTL Accounting
196
246
 
197
247
  | Mongo replay option | Default | Contract |
@@ -210,7 +260,7 @@ const storeProvider = createMongoOidcVaultStore({
210
260
  await storeProvider.ready();
211
261
  ```
212
262
 
213
- `dpopReplayMaxEntries` is a positive safe integer, default **100000**, shared across independent clients using the paired replay collections. All seven collection names must be distinct and valid. Every shared client must use the same capacity and proof/accounting collection pairing; readiness fails if the persisted capacity configuration disagrees. Changing names starts an independent namespace, not a migration. No instance-local admission counter or replay fallback is used.
263
+ `dpopReplayMaxEntries` is a positive safe integer, default **100000**, shared across independent clients using the paired replay collections. All nine collection names must be distinct and valid. Every shared client must use the same capacity and proof/accounting collection pairing; readiness fails if the persisted capacity configuration disagrees. Changing names starts an independent namespace, not a migration. No instance-local admission counter or replay fallback is used.
214
264
 
215
265
  `reserveDpopProof({ replayKey, expiresAt })` reserves an opaque key verbatim after proof verification. Expiry must be a safe-integer future millisecond epoch with remaining TTL **<=360000 ms**. Nonfinite/unsafe/fractional/expired (`<= now()`) or overlong windows return `false` without allocation. A duplicate returns `false` without extending expiry, before capacity checks. Valid expired IDs may be admitted again.
216
266
 
package/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Db } from 'mongodb';
2
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
3
3
 
4
4
  /**
5
5
  * Options for `createMongoOidcVaultStore`.
@@ -9,9 +9,16 @@ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oid
9
9
  * from `ready()`, so applications should await readiness before accepting
10
10
  * traffic. Rotation and shared replay admission require transactions; standalone servers
11
11
  * fail closed instead of using non-atomic multi-write fallback behavior.
12
+ *
13
+ * Store-owned collection reads (including readiness) and all store transactions
14
+ * use primary, overriding URI/client/Db secondary preferences only for vault work.
15
+ * The supplied Db, namespaces, unrelated collections, BSON options and existing
16
+ * read/write concerns are retained; no extra client is created. An unavailable
17
+ * primary causes an error, never secondary or memory fallback. Primary selection
18
+ * does not add linearizable reads, cross-call causal sessions or failover durability.
12
19
  */
13
20
  interface MongoOidcVaultStoreOptions {
14
- /** MongoDB database handle. The caller owns the underlying client lifecycle. */
21
+ /** Connected caller-owned database; vault handles/transactions use primary without changing its defaults or client lifecycle. */
15
22
  db: Db;
16
23
  /** Collection for authorization transactions, keyed by OIDC `state`. */
17
24
  authorizationTransactionsCollectionName?: string;
@@ -29,6 +36,12 @@ interface MongoOidcVaultStoreOptions {
29
36
  dpopReplayCapacityCollectionName?: string;
30
37
  /** Positive safe integer shared by both replay collections; default 100000. All clients must configure the same limit. */
31
38
  dpopReplayMaxEntries?: number;
39
+ /** TTL payloads; default `oidc_vault_logout_navigation_tickets`. Distinct from every other role. */
40
+ logoutNavigationTicketsCollectionName?: string;
41
+ /** Non-TTL serialization/expiry ledger; default `oidc_vault_logout_navigation_capacity`. */
42
+ logoutNavigationCapacityCollectionName?: string;
43
+ /** Independent ticket cap across namespaces in the paired collections; positive safe integer, default 10000. */
44
+ logoutNavigationMaxEntries?: number;
32
45
  /**
33
46
  * Finite alias retention for sessions without explicit expiry, in
34
47
  * milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
@@ -42,15 +55,19 @@ interface MongoOidcVaultStoreOptions {
42
55
  *
43
56
  * Create the store after connecting the MongoDB client, then await `ready()`
44
57
  * before accepting traffic. The application owns the MongoDB client lifecycle,
45
- * including shutdown.
58
+ * including shutdown. Vault reads/transactions require primary without changing
59
+ * caller defaults or strengthening read/write concerns.
46
60
  */
47
61
  interface OidcVaultMongoStoreProvider extends OidcVaultDeviceBindingStoreProvider {
62
+ createLogoutNavigationTicket: OidcVaultLogoutNavigationStoreProvider['createLogoutNavigationTicket'];
63
+ consumeLogoutNavigationTicket: OidcVaultLogoutNavigationStoreProvider['consumeLogoutNavigationTicket'];
48
64
  /**
49
65
  * Waits for startup validation to complete.
50
66
  *
51
67
  * Readiness validates collection names, creates required indexes, and verifies
52
68
  * the MongoDB deployment supports rotation/replay transactions and matching
53
- * shared replay-capacity configuration.
69
+ * shared replay/ticket-capacity configuration and ticket collection pairing.
70
+ * Index/configuration reads use primary even if caller defaults prefer secondaries.
54
71
  * If readiness fails, every store operation rejects with the same error.
55
72
  */
56
73
  ready(): Promise<void>;
@@ -65,8 +82,12 @@ declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
65
82
  * Pass a `Db` from an already-connected caller-owned `MongoClient`, await
66
83
  * `store.ready()` before accepting traffic, and close the client from your own
67
84
  * shutdown path. Readiness validates all configured collection names, creates
68
- * required indexes for the seven store collections, validates shared replay
69
- * capacity, and verifies transaction support for rotation/replay admission.
85
+ * required indexes for nine store collections, validates shared replay/ticket
86
+ * capacity and pairing, and verifies transaction support for rotation/replay/tickets.
87
+ * Store collection reads and transactions use primary even when caller defaults
88
+ * prefer secondaries. Caller defaults/namespaces/concerns are preserved; no extra
89
+ * client or secondary/memory fallback is used. This does not add linearizable
90
+ * reads, cross-call causal sessions or failover durability.
70
91
  */
71
92
  declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
72
93
 
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Db } from 'mongodb';
2
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
3
3
 
4
4
  /**
5
5
  * Options for `createMongoOidcVaultStore`.
@@ -9,9 +9,16 @@ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oid
9
9
  * from `ready()`, so applications should await readiness before accepting
10
10
  * traffic. Rotation and shared replay admission require transactions; standalone servers
11
11
  * fail closed instead of using non-atomic multi-write fallback behavior.
12
+ *
13
+ * Store-owned collection reads (including readiness) and all store transactions
14
+ * use primary, overriding URI/client/Db secondary preferences only for vault work.
15
+ * The supplied Db, namespaces, unrelated collections, BSON options and existing
16
+ * read/write concerns are retained; no extra client is created. An unavailable
17
+ * primary causes an error, never secondary or memory fallback. Primary selection
18
+ * does not add linearizable reads, cross-call causal sessions or failover durability.
12
19
  */
13
20
  interface MongoOidcVaultStoreOptions {
14
- /** MongoDB database handle. The caller owns the underlying client lifecycle. */
21
+ /** Connected caller-owned database; vault handles/transactions use primary without changing its defaults or client lifecycle. */
15
22
  db: Db;
16
23
  /** Collection for authorization transactions, keyed by OIDC `state`. */
17
24
  authorizationTransactionsCollectionName?: string;
@@ -29,6 +36,12 @@ interface MongoOidcVaultStoreOptions {
29
36
  dpopReplayCapacityCollectionName?: string;
30
37
  /** Positive safe integer shared by both replay collections; default 100000. All clients must configure the same limit. */
31
38
  dpopReplayMaxEntries?: number;
39
+ /** TTL payloads; default `oidc_vault_logout_navigation_tickets`. Distinct from every other role. */
40
+ logoutNavigationTicketsCollectionName?: string;
41
+ /** Non-TTL serialization/expiry ledger; default `oidc_vault_logout_navigation_capacity`. */
42
+ logoutNavigationCapacityCollectionName?: string;
43
+ /** Independent ticket cap across namespaces in the paired collections; positive safe integer, default 10000. */
44
+ logoutNavigationMaxEntries?: number;
32
45
  /**
33
46
  * Finite alias retention for sessions without explicit expiry, in
34
47
  * milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
@@ -42,15 +55,19 @@ interface MongoOidcVaultStoreOptions {
42
55
  *
43
56
  * Create the store after connecting the MongoDB client, then await `ready()`
44
57
  * before accepting traffic. The application owns the MongoDB client lifecycle,
45
- * including shutdown.
58
+ * including shutdown. Vault reads/transactions require primary without changing
59
+ * caller defaults or strengthening read/write concerns.
46
60
  */
47
61
  interface OidcVaultMongoStoreProvider extends OidcVaultDeviceBindingStoreProvider {
62
+ createLogoutNavigationTicket: OidcVaultLogoutNavigationStoreProvider['createLogoutNavigationTicket'];
63
+ consumeLogoutNavigationTicket: OidcVaultLogoutNavigationStoreProvider['consumeLogoutNavigationTicket'];
48
64
  /**
49
65
  * Waits for startup validation to complete.
50
66
  *
51
67
  * Readiness validates collection names, creates required indexes, and verifies
52
68
  * the MongoDB deployment supports rotation/replay transactions and matching
53
- * shared replay-capacity configuration.
69
+ * shared replay/ticket-capacity configuration and ticket collection pairing.
70
+ * Index/configuration reads use primary even if caller defaults prefer secondaries.
54
71
  * If readiness fails, every store operation rejects with the same error.
55
72
  */
56
73
  ready(): Promise<void>;
@@ -65,8 +82,12 @@ declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
65
82
  * Pass a `Db` from an already-connected caller-owned `MongoClient`, await
66
83
  * `store.ready()` before accepting traffic, and close the client from your own
67
84
  * shutdown path. Readiness validates all configured collection names, creates
68
- * required indexes for the seven store collections, validates shared replay
69
- * capacity, and verifies transaction support for rotation/replay admission.
85
+ * required indexes for nine store collections, validates shared replay/ticket
86
+ * capacity and pairing, and verifies transaction support for rotation/replay/tickets.
87
+ * Store collection reads and transactions use primary even when caller defaults
88
+ * prefer secondaries. Caller defaults/namespaces/concerns are preserved; no extra
89
+ * client or secondary/memory fallback is used. This does not add linearizable
90
+ * reads, cross-call causal sessions or failover durability.
70
91
  */
71
92
  declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
72
93
 
package/index.js CHANGED
@@ -26,7 +26,7 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/store.ts
29
- var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
29
+ var import_express_oidc_vault4 = require("@web-ts-toolkit/express-oidc-vault");
30
30
  var import_node_util = require("util");
31
31
 
32
32
  // src/binding.ts
@@ -187,7 +187,9 @@ var DEFAULT_COLLECTION_NAMES = {
187
187
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
188
188
  rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
189
189
  dpopProofs: "oidc_vault_dpop_proofs",
190
- dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
190
+ dpopReplayCapacity: "oidc_vault_dpop_replay_capacity",
191
+ logoutNavigationTickets: "oidc_vault_logout_navigation_tickets",
192
+ logoutNavigationCapacity: "oidc_vault_logout_navigation_capacity"
191
193
  };
192
194
  var validateCollectionName = (role, name) => {
193
195
  if (name.length === 0) {
@@ -211,7 +213,9 @@ var resolveCollectionNames = (options) => {
211
213
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
212
214
  rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
213
215
  dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
214
- dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
216
+ dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity,
217
+ logoutNavigationTickets: options.logoutNavigationTicketsCollectionName ?? DEFAULT_COLLECTION_NAMES.logoutNavigationTickets,
218
+ logoutNavigationCapacity: options.logoutNavigationCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.logoutNavigationCapacity
215
219
  };
216
220
  for (const [role, name] of Object.entries(collectionNames)) {
217
221
  validateCollectionName(role, name);
@@ -254,6 +258,11 @@ var ensureStoreIndexes = async (collections) => {
254
258
  createTtlIndex(collections.backchannelLogoutTokenJtis),
255
259
  createTtlIndex(collections.rotatedSessionAliases),
256
260
  createTtlIndex(collections.dpopProofs),
261
+ createTtlIndex(collections.logoutNavigationTickets),
262
+ collections.logoutNavigationCapacity.createIndex(
263
+ { kind: 1, expiresAt: 1 },
264
+ { name: "logout_navigation_expiry_accounting_idx" }
265
+ ),
257
266
  // Accounting outlives physical proof TTL deletion until a serialized
258
267
  // bounded cleanup decrements capacity. Never TTL-delete these entries.
259
268
  collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
@@ -361,7 +370,7 @@ var MongoDpopReplayReservations = class {
361
370
  await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
362
371
  return true;
363
372
  },
364
- { readConcern: { level: "snapshot" } }
373
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
365
374
  );
366
375
  return result === true;
367
376
  } finally {
@@ -384,6 +393,219 @@ var MongoDpopReplayReservations = class {
384
393
  }
385
394
  };
386
395
 
396
+ // src/read-policy.ts
397
+ var primaryCollection = (db, name) => db.collection(name, { readPreference: "primary" });
398
+
399
+ // src/logout-tickets.ts
400
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
401
+
402
+ // src/logout-ticket-validation.ts
403
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
404
+ var PREFIX = "logout-navigation:v1:";
405
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
406
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
407
+ var ticketNow = (now) => {
408
+ if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
409
+ return now;
410
+ };
411
+ var captureTicketIdentity = (input) => ({
412
+ namespace: input?.namespace,
413
+ ticketHash: input?.ticketHash
414
+ });
415
+ 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);
416
+ var captureTicket = (input) => ({
417
+ ...captureTicketIdentity(input),
418
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
419
+ createdAt: input?.createdAt,
420
+ expiresAt: input?.expiresAt
421
+ });
422
+ var validTicket = (record, now) => {
423
+ 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)
424
+ return false;
425
+ try {
426
+ const url = new URL(record.upstreamLogoutUrl);
427
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
428
+ } catch {
429
+ return false;
430
+ }
431
+ };
432
+ var assertTicket = (record, now) => {
433
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
434
+ };
435
+ var ticketLimit = (value) => {
436
+ const max = value === void 0 ? 1e4 : value;
437
+ if (!Number.isSafeInteger(max) || max <= 0)
438
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
439
+ return max;
440
+ };
441
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
442
+
443
+ // src/logout-tickets.ts
444
+ var CONFIG = "configuration";
445
+ var CAPACITY = "capacity";
446
+ var ledgerId = (id) => `ticket:${id}`;
447
+ var accountingError = () => new Error("OIDC vault MongoDB logout navigation configuration/accounting is inconsistent.");
448
+ var isDate2 = (date) => date instanceof Date && usableEpoch(date.getTime());
449
+ var MongoLogoutNavigationTickets = class {
450
+ constructor(db, tickets, capacity, maxEntries, now) {
451
+ this.db = db;
452
+ this.tickets = tickets;
453
+ this.capacity = capacity;
454
+ this.maxEntries = maxEntries;
455
+ this.now = now;
456
+ }
457
+ async initialize() {
458
+ if ((await this.capacity.listIndexes().toArray()).some((index) => index.expireAfterSeconds !== void 0))
459
+ throw new Error("OIDC vault MongoDB logout navigation capacity accounting must not have TTL indexes.");
460
+ for (const initialize of [
461
+ () => this.tickets.updateOne(
462
+ { _id: CONFIG },
463
+ { $setOnInsert: { capacityCollectionName: this.capacity.collectionName, maxEntries: this.maxEntries } },
464
+ { upsert: true }
465
+ ),
466
+ () => this.capacity.updateOne(
467
+ { _id: CAPACITY },
468
+ {
469
+ $setOnInsert: {
470
+ kind: "capacity",
471
+ ticketsCollectionName: this.tickets.collectionName,
472
+ maxEntries: this.maxEntries,
473
+ entries: 0,
474
+ revision: 0
475
+ }
476
+ },
477
+ { upsert: true }
478
+ )
479
+ ]) {
480
+ try {
481
+ await initialize();
482
+ } catch (error) {
483
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
484
+ }
485
+ }
486
+ this.validateConfig(await this.tickets.findOne({ _id: CONFIG }));
487
+ this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY }));
488
+ }
489
+ create(record) {
490
+ assertTicket(record, ticketNow(this.now()));
491
+ return this.perform(record, record).then(() => void 0);
492
+ }
493
+ consume(identity) {
494
+ return this.perform(identity);
495
+ }
496
+ async perform(identity, creating) {
497
+ const id = ticketId(identity);
498
+ const session = this.db.client.startSession();
499
+ try {
500
+ const result = await session.withTransaction(
501
+ async () => {
502
+ const capacity = await this.capacity.findOneAndUpdate(
503
+ { _id: CAPACITY },
504
+ { $inc: { revision: 1 } },
505
+ { session, returnDocument: "after" }
506
+ );
507
+ this.validateCapacity(capacity);
508
+ this.validateConfig(await this.tickets.findOne({ _id: CONFIG }, { session }));
509
+ if (await this.capacity.countDocuments({ _id: { $ne: CAPACITY } }, { session }) !== capacity.entries)
510
+ throw accountingError();
511
+ const now = ticketNow(this.now());
512
+ if (creating) assertTicket(creating, now);
513
+ const payload = await this.tickets.findOne({ _id: id }, { session });
514
+ const selected = await this.capacity.findOne({ _id: ledgerId(id) }, { session });
515
+ if (selected) this.validateLedger(selected);
516
+ if (payload && !selected) throw accountingError();
517
+ const record = payload && "expiresAt" in payload && isDate2(payload.expiresAt) && Object.keys(payload).length === 6 ? {
518
+ namespace: payload.namespace,
519
+ ticketHash: payload.ticketHash,
520
+ upstreamLogoutUrl: payload.upstreamLogoutUrl,
521
+ createdAt: payload.createdAt,
522
+ expiresAt: payload.expiresAt.getTime()
523
+ } : null;
524
+ const valid = record && validTicket(record, now) && ticketId(record) === id;
525
+ if (valid && selected.expiresAt.getTime() !== record.expiresAt) throw accountingError();
526
+ if (creating && selected && selected.expiresAt.getTime() > now && (!payload || valid))
527
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError();
528
+ const expired = await this.capacity.find({ kind: "ticket", _id: { $ne: ledgerId(id) }, expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
529
+ for (const row of expired) this.validateLedger(row);
530
+ if (expired.length) {
531
+ const payloads = await this.tickets.find(
532
+ {
533
+ _id: {
534
+ $in: expired.map((row) => ticketId({ namespace: row.namespace, ticketHash: row.ticketHash }))
535
+ }
536
+ },
537
+ { session }
538
+ ).toArray();
539
+ for (const payload2 of payloads)
540
+ if ("expiresAt" in payload2 && isDate2(payload2.expiresAt) && payload2.expiresAt.getTime() > now)
541
+ throw accountingError();
542
+ }
543
+ const retired = [...expired, ...selected ? [selected] : []];
544
+ if (retired.length) {
545
+ const removed = await this.capacity.deleteMany(
546
+ { _id: { $in: retired.map((row) => row._id) } },
547
+ { session }
548
+ );
549
+ if (removed.deletedCount !== retired.length) throw accountingError();
550
+ await this.tickets.deleteMany(
551
+ {
552
+ _id: {
553
+ $in: retired.map((row) => ticketId({ namespace: row.namespace, ticketHash: row.ticketHash }))
554
+ }
555
+ },
556
+ { session }
557
+ );
558
+ }
559
+ const entries = capacity.entries - retired.length;
560
+ if (entries < 0) throw accountingError();
561
+ if (creating) {
562
+ if (entries >= this.maxEntries) throw new import_express_oidc_vault3.OidcVaultLogoutNavigationCapacityError();
563
+ await this.tickets.insertOne(
564
+ { _id: id, ...creating, expiresAt: new Date(creating.expiresAt) },
565
+ { session }
566
+ );
567
+ await this.capacity.insertOne(
568
+ {
569
+ _id: ledgerId(id),
570
+ kind: "ticket",
571
+ namespace: identity.namespace,
572
+ ticketHash: identity.ticketHash,
573
+ expiresAt: new Date(creating.expiresAt)
574
+ },
575
+ { session }
576
+ );
577
+ }
578
+ await this.capacity.updateOne(
579
+ { _id: CAPACITY },
580
+ { $set: { entries: entries + (creating ? 1 : 0) } },
581
+ { session }
582
+ );
583
+ const finalNow = ticketNow(this.now());
584
+ if (creating) assertTicket(creating, finalNow);
585
+ return !creating && valid && validTicket(record, finalNow) ? record : null;
586
+ },
587
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
588
+ );
589
+ return result ?? null;
590
+ } finally {
591
+ await session.endSession();
592
+ }
593
+ }
594
+ validateConfig(row) {
595
+ if (!row || !("capacityCollectionName" in row) || row.capacityCollectionName !== this.capacity.collectionName || row.maxEntries !== this.maxEntries)
596
+ throw accountingError();
597
+ }
598
+ validateCapacity(row) {
599
+ if (!row || row.kind !== "capacity" || row.maxEntries !== this.maxEntries || row.ticketsCollectionName !== this.tickets.collectionName || !Number.isSafeInteger(row.entries) || row.entries < 0 || row.entries > this.maxEntries || !Number.isSafeInteger(row.revision) || row.revision < 0)
600
+ throw accountingError();
601
+ }
602
+ validateLedger(row) {
603
+ const identity = { namespace: row.namespace, ticketHash: row.ticketHash };
604
+ if (row.kind !== "ticket" || !validTicketIdentity(identity) || row._id !== ledgerId(ticketId(identity)) || !isDate2(row.expiresAt))
605
+ throw accountingError();
606
+ }
607
+ };
608
+
387
609
  // src/store.ts
388
610
  var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
389
611
  if (input === null || typeof input !== "object") return input;
@@ -415,29 +637,51 @@ var isSameSessionDocumentGeneration = (current, expected) => current._id === exp
415
637
  var MongoOidcVaultStore = class {
416
638
  constructor(options) {
417
639
  const collectionNames = resolveCollectionNames(options);
640
+ const logoutMaxEntries = ticketLimit(options.logoutNavigationMaxEntries);
418
641
  const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
419
642
  if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
420
643
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
421
644
  }
422
645
  this.db = options.db;
423
- this.authorizationTransactions = options.db.collection(
646
+ this.authorizationTransactions = primaryCollection(
647
+ this.db,
424
648
  collectionNames.authorizationTransactions
425
649
  );
426
- this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
427
- this.sessions = options.db.collection(collectionNames.sessions);
428
- this.backchannelLogoutTokenJtis = options.db.collection(
650
+ this.exchangeCodes = primaryCollection(this.db, collectionNames.exchangeCodes);
651
+ this.sessions = primaryCollection(this.db, collectionNames.sessions);
652
+ this.backchannelLogoutTokenJtis = primaryCollection(
653
+ this.db,
429
654
  collectionNames.backchannelLogoutTokenJtis
430
655
  );
431
- this.rotatedSessionAliases = options.db.collection(
656
+ this.rotatedSessionAliases = primaryCollection(
657
+ this.db,
432
658
  collectionNames.rotatedSessionAliases
433
659
  );
434
- this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
435
- this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
660
+ this.dpopProofs = primaryCollection(this.db, collectionNames.dpopProofs);
661
+ this.dpopReplayCapacity = primaryCollection(
662
+ this.db,
663
+ collectionNames.dpopReplayCapacity
664
+ );
665
+ this.logoutNavigationTickets = primaryCollection(
666
+ this.db,
667
+ collectionNames.logoutNavigationTickets
668
+ );
669
+ this.logoutNavigationCapacity = primaryCollection(
670
+ this.db,
671
+ collectionNames.logoutNavigationCapacity
672
+ );
436
673
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
437
674
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
438
675
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
439
676
  }
440
677
  this.now = options.now ?? (() => Date.now());
678
+ this.logoutTickets = new MongoLogoutNavigationTickets(
679
+ this.db,
680
+ this.logoutNavigationTickets,
681
+ this.logoutNavigationCapacity,
682
+ logoutMaxEntries,
683
+ this.now
684
+ );
441
685
  this.dpopReplay = new MongoDpopReplayReservations(
442
686
  this.db,
443
687
  this.dpopProofs,
@@ -452,6 +696,18 @@ var MongoOidcVaultStore = class {
452
696
  async ready() {
453
697
  await this.waitUntilReady();
454
698
  }
699
+ async createLogoutNavigationTicket(input) {
700
+ const record = captureTicket(input);
701
+ assertTicket(record);
702
+ await this.waitUntilReady();
703
+ await this.logoutTickets.create(record);
704
+ }
705
+ async consumeLogoutNavigationTicket(input) {
706
+ const identity = captureTicketIdentity(input);
707
+ if (!validTicketIdentity(identity)) return null;
708
+ await this.waitUntilReady();
709
+ return this.logoutTickets.consume(identity);
710
+ }
455
711
  async createAuthorizationTransaction(input) {
456
712
  input = snapshotInput({ ...input });
457
713
  assertRecordBinding(input);
@@ -580,7 +836,7 @@ var MongoOidcVaultStore = class {
580
836
  }
581
837
  return source ? toRevocationContext(source) : null;
582
838
  },
583
- { readConcern: { level: "snapshot" } }
839
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
584
840
  ) ?? null;
585
841
  } finally {
586
842
  await transaction.endSession();
@@ -596,13 +852,13 @@ var MongoOidcVaultStore = class {
596
852
  assertSessionBinding(input.nextSession);
597
853
  await this.waitUntilReady();
598
854
  if (input.nextSession.sessionId === input.sessionId) {
599
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
855
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
600
856
  }
601
857
  try {
602
858
  return await this.rotateSessionWithTransaction(input);
603
859
  } catch (error) {
604
860
  if (isDuplicateKeyError(error)) {
605
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
861
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
606
862
  }
607
863
  throw error;
608
864
  }
@@ -703,11 +959,14 @@ var MongoOidcVaultStore = class {
703
959
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
704
960
  rotatedSessionAliases: this.rotatedSessionAliases,
705
961
  dpopProofs: this.dpopProofs,
706
- dpopReplayCapacity: this.dpopReplayCapacity
962
+ dpopReplayCapacity: this.dpopReplayCapacity,
963
+ logoutNavigationTickets: this.logoutNavigationTickets,
964
+ logoutNavigationCapacity: this.logoutNavigationCapacity
707
965
  }),
708
966
  assertTransactionSupport(this.db)
709
967
  ]);
710
968
  await this.dpopReplay.initialize();
969
+ await this.logoutTickets.initialize();
711
970
  }
712
971
  async waitUntilReady() {
713
972
  await this.initialization;
@@ -720,21 +979,24 @@ var MongoOidcVaultStore = class {
720
979
  const expectedSource = sessionToDocument(previous);
721
980
  const session = this.db.client.startSession();
722
981
  try {
723
- await session.withTransaction(async () => {
724
- const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
725
- if (!current || isExpired(current, this.now())) {
726
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
727
- }
728
- if (!isSameSessionDocumentGeneration(current, expectedSource)) {
729
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
730
- }
731
- await this.sessions.insertOne(sessionToDocument(nextSession), { session });
732
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
733
- if (deleteResult.deletedCount !== 1) {
734
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
735
- }
736
- await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
737
- });
982
+ await session.withTransaction(
983
+ async () => {
984
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
985
+ if (!current || isExpired(current, this.now())) {
986
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
987
+ }
988
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
989
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
990
+ }
991
+ await this.sessions.insertOne(sessionToDocument(nextSession), { session });
992
+ const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
993
+ if (deleteResult.deletedCount !== 1) {
994
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
995
+ }
996
+ await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
997
+ },
998
+ { readPreference: "primary" }
999
+ );
738
1000
  return nextSession;
739
1001
  } finally {
740
1002
  await session.endSession();
@@ -761,7 +1023,7 @@ var MongoOidcVaultStore = class {
761
1023
  async normalizeRotatedSession(input) {
762
1024
  const previous = await this.getSession(input.sessionId);
763
1025
  if (!previous) {
764
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
1026
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
765
1027
  }
766
1028
  inheritSessionBinding(previous, input.nextSession);
767
1029
  return {
@@ -818,7 +1080,7 @@ var MongoOidcVaultStore = class {
818
1080
  await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
819
1081
  }
820
1082
  },
821
- { readConcern: { level: "snapshot" } }
1083
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
822
1084
  );
823
1085
  } finally {
824
1086
  await session.endSession();
package/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/store.ts
2
2
  import {
3
- OidcVaultStoreConflictError as OidcVaultStoreConflictError2
3
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError3
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
5
  import { isDeepStrictEqual } from "util";
6
6
 
@@ -162,7 +162,9 @@ var DEFAULT_COLLECTION_NAMES = {
162
162
  backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
163
163
  rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
164
164
  dpopProofs: "oidc_vault_dpop_proofs",
165
- dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
165
+ dpopReplayCapacity: "oidc_vault_dpop_replay_capacity",
166
+ logoutNavigationTickets: "oidc_vault_logout_navigation_tickets",
167
+ logoutNavigationCapacity: "oidc_vault_logout_navigation_capacity"
166
168
  };
167
169
  var validateCollectionName = (role, name) => {
168
170
  if (name.length === 0) {
@@ -186,7 +188,9 @@ var resolveCollectionNames = (options) => {
186
188
  backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
187
189
  rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
188
190
  dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
189
- dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
191
+ dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity,
192
+ logoutNavigationTickets: options.logoutNavigationTicketsCollectionName ?? DEFAULT_COLLECTION_NAMES.logoutNavigationTickets,
193
+ logoutNavigationCapacity: options.logoutNavigationCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.logoutNavigationCapacity
190
194
  };
191
195
  for (const [role, name] of Object.entries(collectionNames)) {
192
196
  validateCollectionName(role, name);
@@ -229,6 +233,11 @@ var ensureStoreIndexes = async (collections) => {
229
233
  createTtlIndex(collections.backchannelLogoutTokenJtis),
230
234
  createTtlIndex(collections.rotatedSessionAliases),
231
235
  createTtlIndex(collections.dpopProofs),
236
+ createTtlIndex(collections.logoutNavigationTickets),
237
+ collections.logoutNavigationCapacity.createIndex(
238
+ { kind: 1, expiresAt: 1 },
239
+ { name: "logout_navigation_expiry_accounting_idx" }
240
+ ),
232
241
  // Accounting outlives physical proof TTL deletion until a serialized
233
242
  // bounded cleanup decrements capacity. Never TTL-delete these entries.
234
243
  collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
@@ -336,7 +345,7 @@ var MongoDpopReplayReservations = class {
336
345
  await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
337
346
  return true;
338
347
  },
339
- { readConcern: { level: "snapshot" } }
348
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
340
349
  );
341
350
  return result === true;
342
351
  } finally {
@@ -359,6 +368,222 @@ var MongoDpopReplayReservations = class {
359
368
  }
360
369
  };
361
370
 
371
+ // src/read-policy.ts
372
+ var primaryCollection = (db, name) => db.collection(name, { readPreference: "primary" });
373
+
374
+ // src/logout-tickets.ts
375
+ import {
376
+ OidcVaultLogoutNavigationCapacityError,
377
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError2
378
+ } from "@web-ts-toolkit/express-oidc-vault";
379
+
380
+ // src/logout-ticket-validation.ts
381
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
382
+ var PREFIX = "logout-navigation:v1:";
383
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
384
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
385
+ var ticketNow = (now) => {
386
+ if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
387
+ return now;
388
+ };
389
+ var captureTicketIdentity = (input) => ({
390
+ namespace: input?.namespace,
391
+ ticketHash: input?.ticketHash
392
+ });
393
+ 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);
394
+ var captureTicket = (input) => ({
395
+ ...captureTicketIdentity(input),
396
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
397
+ createdAt: input?.createdAt,
398
+ expiresAt: input?.expiresAt
399
+ });
400
+ var validTicket = (record, now) => {
401
+ 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)
402
+ return false;
403
+ try {
404
+ const url = new URL(record.upstreamLogoutUrl);
405
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
406
+ } catch {
407
+ return false;
408
+ }
409
+ };
410
+ var assertTicket = (record, now) => {
411
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
412
+ };
413
+ var ticketLimit = (value) => {
414
+ const max = value === void 0 ? 1e4 : value;
415
+ if (!Number.isSafeInteger(max) || max <= 0)
416
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
417
+ return max;
418
+ };
419
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
420
+
421
+ // src/logout-tickets.ts
422
+ var CONFIG = "configuration";
423
+ var CAPACITY = "capacity";
424
+ var ledgerId = (id) => `ticket:${id}`;
425
+ var accountingError = () => new Error("OIDC vault MongoDB logout navigation configuration/accounting is inconsistent.");
426
+ var isDate2 = (date) => date instanceof Date && usableEpoch(date.getTime());
427
+ var MongoLogoutNavigationTickets = class {
428
+ constructor(db, tickets, capacity, maxEntries, now) {
429
+ this.db = db;
430
+ this.tickets = tickets;
431
+ this.capacity = capacity;
432
+ this.maxEntries = maxEntries;
433
+ this.now = now;
434
+ }
435
+ async initialize() {
436
+ if ((await this.capacity.listIndexes().toArray()).some((index) => index.expireAfterSeconds !== void 0))
437
+ throw new Error("OIDC vault MongoDB logout navigation capacity accounting must not have TTL indexes.");
438
+ for (const initialize of [
439
+ () => this.tickets.updateOne(
440
+ { _id: CONFIG },
441
+ { $setOnInsert: { capacityCollectionName: this.capacity.collectionName, maxEntries: this.maxEntries } },
442
+ { upsert: true }
443
+ ),
444
+ () => this.capacity.updateOne(
445
+ { _id: CAPACITY },
446
+ {
447
+ $setOnInsert: {
448
+ kind: "capacity",
449
+ ticketsCollectionName: this.tickets.collectionName,
450
+ maxEntries: this.maxEntries,
451
+ entries: 0,
452
+ revision: 0
453
+ }
454
+ },
455
+ { upsert: true }
456
+ )
457
+ ]) {
458
+ try {
459
+ await initialize();
460
+ } catch (error) {
461
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
462
+ }
463
+ }
464
+ this.validateConfig(await this.tickets.findOne({ _id: CONFIG }));
465
+ this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY }));
466
+ }
467
+ create(record) {
468
+ assertTicket(record, ticketNow(this.now()));
469
+ return this.perform(record, record).then(() => void 0);
470
+ }
471
+ consume(identity) {
472
+ return this.perform(identity);
473
+ }
474
+ async perform(identity, creating) {
475
+ const id = ticketId(identity);
476
+ const session = this.db.client.startSession();
477
+ try {
478
+ const result = await session.withTransaction(
479
+ async () => {
480
+ const capacity = await this.capacity.findOneAndUpdate(
481
+ { _id: CAPACITY },
482
+ { $inc: { revision: 1 } },
483
+ { session, returnDocument: "after" }
484
+ );
485
+ this.validateCapacity(capacity);
486
+ this.validateConfig(await this.tickets.findOne({ _id: CONFIG }, { session }));
487
+ if (await this.capacity.countDocuments({ _id: { $ne: CAPACITY } }, { session }) !== capacity.entries)
488
+ throw accountingError();
489
+ const now = ticketNow(this.now());
490
+ if (creating) assertTicket(creating, now);
491
+ const payload = await this.tickets.findOne({ _id: id }, { session });
492
+ const selected = await this.capacity.findOne({ _id: ledgerId(id) }, { session });
493
+ if (selected) this.validateLedger(selected);
494
+ if (payload && !selected) throw accountingError();
495
+ const record = payload && "expiresAt" in payload && isDate2(payload.expiresAt) && Object.keys(payload).length === 6 ? {
496
+ namespace: payload.namespace,
497
+ ticketHash: payload.ticketHash,
498
+ upstreamLogoutUrl: payload.upstreamLogoutUrl,
499
+ createdAt: payload.createdAt,
500
+ expiresAt: payload.expiresAt.getTime()
501
+ } : null;
502
+ const valid = record && validTicket(record, now) && ticketId(record) === id;
503
+ if (valid && selected.expiresAt.getTime() !== record.expiresAt) throw accountingError();
504
+ if (creating && selected && selected.expiresAt.getTime() > now && (!payload || valid))
505
+ throw new OidcVaultStoreConflictError2();
506
+ const expired = await this.capacity.find({ kind: "ticket", _id: { $ne: ledgerId(id) }, expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
507
+ for (const row of expired) this.validateLedger(row);
508
+ if (expired.length) {
509
+ const payloads = await this.tickets.find(
510
+ {
511
+ _id: {
512
+ $in: expired.map((row) => ticketId({ namespace: row.namespace, ticketHash: row.ticketHash }))
513
+ }
514
+ },
515
+ { session }
516
+ ).toArray();
517
+ for (const payload2 of payloads)
518
+ if ("expiresAt" in payload2 && isDate2(payload2.expiresAt) && payload2.expiresAt.getTime() > now)
519
+ throw accountingError();
520
+ }
521
+ const retired = [...expired, ...selected ? [selected] : []];
522
+ if (retired.length) {
523
+ const removed = await this.capacity.deleteMany(
524
+ { _id: { $in: retired.map((row) => row._id) } },
525
+ { session }
526
+ );
527
+ if (removed.deletedCount !== retired.length) throw accountingError();
528
+ await this.tickets.deleteMany(
529
+ {
530
+ _id: {
531
+ $in: retired.map((row) => ticketId({ namespace: row.namespace, ticketHash: row.ticketHash }))
532
+ }
533
+ },
534
+ { session }
535
+ );
536
+ }
537
+ const entries = capacity.entries - retired.length;
538
+ if (entries < 0) throw accountingError();
539
+ if (creating) {
540
+ if (entries >= this.maxEntries) throw new OidcVaultLogoutNavigationCapacityError();
541
+ await this.tickets.insertOne(
542
+ { _id: id, ...creating, expiresAt: new Date(creating.expiresAt) },
543
+ { session }
544
+ );
545
+ await this.capacity.insertOne(
546
+ {
547
+ _id: ledgerId(id),
548
+ kind: "ticket",
549
+ namespace: identity.namespace,
550
+ ticketHash: identity.ticketHash,
551
+ expiresAt: new Date(creating.expiresAt)
552
+ },
553
+ { session }
554
+ );
555
+ }
556
+ await this.capacity.updateOne(
557
+ { _id: CAPACITY },
558
+ { $set: { entries: entries + (creating ? 1 : 0) } },
559
+ { session }
560
+ );
561
+ const finalNow = ticketNow(this.now());
562
+ if (creating) assertTicket(creating, finalNow);
563
+ return !creating && valid && validTicket(record, finalNow) ? record : null;
564
+ },
565
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
566
+ );
567
+ return result ?? null;
568
+ } finally {
569
+ await session.endSession();
570
+ }
571
+ }
572
+ validateConfig(row) {
573
+ if (!row || !("capacityCollectionName" in row) || row.capacityCollectionName !== this.capacity.collectionName || row.maxEntries !== this.maxEntries)
574
+ throw accountingError();
575
+ }
576
+ validateCapacity(row) {
577
+ if (!row || row.kind !== "capacity" || row.maxEntries !== this.maxEntries || row.ticketsCollectionName !== this.tickets.collectionName || !Number.isSafeInteger(row.entries) || row.entries < 0 || row.entries > this.maxEntries || !Number.isSafeInteger(row.revision) || row.revision < 0)
578
+ throw accountingError();
579
+ }
580
+ validateLedger(row) {
581
+ const identity = { namespace: row.namespace, ticketHash: row.ticketHash };
582
+ if (row.kind !== "ticket" || !validTicketIdentity(identity) || row._id !== ledgerId(ticketId(identity)) || !isDate2(row.expiresAt))
583
+ throw accountingError();
584
+ }
585
+ };
586
+
362
587
  // src/store.ts
363
588
  var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
364
589
  if (input === null || typeof input !== "object") return input;
@@ -390,29 +615,51 @@ var isSameSessionDocumentGeneration = (current, expected) => current._id === exp
390
615
  var MongoOidcVaultStore = class {
391
616
  constructor(options) {
392
617
  const collectionNames = resolveCollectionNames(options);
618
+ const logoutMaxEntries = ticketLimit(options.logoutNavigationMaxEntries);
393
619
  const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
394
620
  if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
395
621
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
396
622
  }
397
623
  this.db = options.db;
398
- this.authorizationTransactions = options.db.collection(
624
+ this.authorizationTransactions = primaryCollection(
625
+ this.db,
399
626
  collectionNames.authorizationTransactions
400
627
  );
401
- this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
402
- this.sessions = options.db.collection(collectionNames.sessions);
403
- this.backchannelLogoutTokenJtis = options.db.collection(
628
+ this.exchangeCodes = primaryCollection(this.db, collectionNames.exchangeCodes);
629
+ this.sessions = primaryCollection(this.db, collectionNames.sessions);
630
+ this.backchannelLogoutTokenJtis = primaryCollection(
631
+ this.db,
404
632
  collectionNames.backchannelLogoutTokenJtis
405
633
  );
406
- this.rotatedSessionAliases = options.db.collection(
634
+ this.rotatedSessionAliases = primaryCollection(
635
+ this.db,
407
636
  collectionNames.rotatedSessionAliases
408
637
  );
409
- this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
410
- this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
638
+ this.dpopProofs = primaryCollection(this.db, collectionNames.dpopProofs);
639
+ this.dpopReplayCapacity = primaryCollection(
640
+ this.db,
641
+ collectionNames.dpopReplayCapacity
642
+ );
643
+ this.logoutNavigationTickets = primaryCollection(
644
+ this.db,
645
+ collectionNames.logoutNavigationTickets
646
+ );
647
+ this.logoutNavigationCapacity = primaryCollection(
648
+ this.db,
649
+ collectionNames.logoutNavigationCapacity
650
+ );
411
651
  this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
412
652
  if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
413
653
  throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
414
654
  }
415
655
  this.now = options.now ?? (() => Date.now());
656
+ this.logoutTickets = new MongoLogoutNavigationTickets(
657
+ this.db,
658
+ this.logoutNavigationTickets,
659
+ this.logoutNavigationCapacity,
660
+ logoutMaxEntries,
661
+ this.now
662
+ );
416
663
  this.dpopReplay = new MongoDpopReplayReservations(
417
664
  this.db,
418
665
  this.dpopProofs,
@@ -427,6 +674,18 @@ var MongoOidcVaultStore = class {
427
674
  async ready() {
428
675
  await this.waitUntilReady();
429
676
  }
677
+ async createLogoutNavigationTicket(input) {
678
+ const record = captureTicket(input);
679
+ assertTicket(record);
680
+ await this.waitUntilReady();
681
+ await this.logoutTickets.create(record);
682
+ }
683
+ async consumeLogoutNavigationTicket(input) {
684
+ const identity = captureTicketIdentity(input);
685
+ if (!validTicketIdentity(identity)) return null;
686
+ await this.waitUntilReady();
687
+ return this.logoutTickets.consume(identity);
688
+ }
430
689
  async createAuthorizationTransaction(input) {
431
690
  input = snapshotInput({ ...input });
432
691
  assertRecordBinding(input);
@@ -555,7 +814,7 @@ var MongoOidcVaultStore = class {
555
814
  }
556
815
  return source ? toRevocationContext(source) : null;
557
816
  },
558
- { readConcern: { level: "snapshot" } }
817
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
559
818
  ) ?? null;
560
819
  } finally {
561
820
  await transaction.endSession();
@@ -571,13 +830,13 @@ var MongoOidcVaultStore = class {
571
830
  assertSessionBinding(input.nextSession);
572
831
  await this.waitUntilReady();
573
832
  if (input.nextSession.sessionId === input.sessionId) {
574
- throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
833
+ throw new OidcVaultStoreConflictError3("OIDC vault session rotation target must use a different session ID.");
575
834
  }
576
835
  try {
577
836
  return await this.rotateSessionWithTransaction(input);
578
837
  } catch (error) {
579
838
  if (isDuplicateKeyError(error)) {
580
- throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
839
+ throw new OidcVaultStoreConflictError3("OIDC vault session rotation target already exists.");
581
840
  }
582
841
  throw error;
583
842
  }
@@ -678,11 +937,14 @@ var MongoOidcVaultStore = class {
678
937
  backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
679
938
  rotatedSessionAliases: this.rotatedSessionAliases,
680
939
  dpopProofs: this.dpopProofs,
681
- dpopReplayCapacity: this.dpopReplayCapacity
940
+ dpopReplayCapacity: this.dpopReplayCapacity,
941
+ logoutNavigationTickets: this.logoutNavigationTickets,
942
+ logoutNavigationCapacity: this.logoutNavigationCapacity
682
943
  }),
683
944
  assertTransactionSupport(this.db)
684
945
  ]);
685
946
  await this.dpopReplay.initialize();
947
+ await this.logoutTickets.initialize();
686
948
  }
687
949
  async waitUntilReady() {
688
950
  await this.initialization;
@@ -695,21 +957,24 @@ var MongoOidcVaultStore = class {
695
957
  const expectedSource = sessionToDocument(previous);
696
958
  const session = this.db.client.startSession();
697
959
  try {
698
- await session.withTransaction(async () => {
699
- const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
700
- if (!current || isExpired(current, this.now())) {
701
- throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
702
- }
703
- if (!isSameSessionDocumentGeneration(current, expectedSource)) {
704
- throw new OidcVaultStoreConflictError2("OIDC vault session changed before rotation could commit.");
705
- }
706
- await this.sessions.insertOne(sessionToDocument(nextSession), { session });
707
- const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
708
- if (deleteResult.deletedCount !== 1) {
709
- throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
710
- }
711
- await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
712
- });
960
+ await session.withTransaction(
961
+ async () => {
962
+ const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
963
+ if (!current || isExpired(current, this.now())) {
964
+ throw new OidcVaultStoreConflictError3("OIDC vault session no longer exists for rotation.");
965
+ }
966
+ if (!isSameSessionDocumentGeneration(current, expectedSource)) {
967
+ throw new OidcVaultStoreConflictError3("OIDC vault session changed before rotation could commit.");
968
+ }
969
+ await this.sessions.insertOne(sessionToDocument(nextSession), { session });
970
+ const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
971
+ if (deleteResult.deletedCount !== 1) {
972
+ throw new OidcVaultStoreConflictError3("OIDC vault session no longer exists for rotation.");
973
+ }
974
+ await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
975
+ },
976
+ { readPreference: "primary" }
977
+ );
713
978
  return nextSession;
714
979
  } finally {
715
980
  await session.endSession();
@@ -736,7 +1001,7 @@ var MongoOidcVaultStore = class {
736
1001
  async normalizeRotatedSession(input) {
737
1002
  const previous = await this.getSession(input.sessionId);
738
1003
  if (!previous) {
739
- throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
1004
+ throw new OidcVaultStoreConflictError3("OIDC vault session no longer exists for rotation.");
740
1005
  }
741
1006
  inheritSessionBinding(previous, input.nextSession);
742
1007
  return {
@@ -793,7 +1058,7 @@ var MongoOidcVaultStore = class {
793
1058
  await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
794
1059
  }
795
1060
  },
796
- { readConcern: { level: "snapshot" } }
1061
+ { readPreference: "primary", readConcern: { level: "snapshot" } }
797
1062
  );
798
1063
  } finally {
799
1064
  await session.endSession();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-mongodb-store",
3
3
  "description": "MongoDB store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-mongodb-store",
5
- "version": "0.53.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.53.0",
32
+ "@web-ts-toolkit/express-oidc-vault": "0.54.0",
33
33
  "mongodb": "^6.20.0"
34
34
  },
35
35
  "files": [