@web-ts-toolkit/express-oidc-vault-mongodb-store 0.52.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 +53 -3
- package/index.d.mts +27 -6
- package/index.d.ts +27 -6
- package/index.js +294 -32
- package/index.mjs +297 -32
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ process.once('SIGTERM', async () => {
|
|
|
74
74
|
|
|
75
75
|
## Collection Names
|
|
76
76
|
|
|
77
|
-
The provider uses
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
69
|
-
* capacity, and verifies transaction support for rotation/replay
|
|
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
|
-
/**
|
|
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
|
|
69
|
-
* capacity, and verifies transaction support for rotation/replay
|
|
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
|
|
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 =
|
|
646
|
+
this.authorizationTransactions = primaryCollection(
|
|
647
|
+
this.db,
|
|
424
648
|
collectionNames.authorizationTransactions
|
|
425
649
|
);
|
|
426
|
-
this.exchangeCodes =
|
|
427
|
-
this.sessions =
|
|
428
|
-
this.backchannelLogoutTokenJtis =
|
|
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 =
|
|
656
|
+
this.rotatedSessionAliases = primaryCollection(
|
|
657
|
+
this.db,
|
|
432
658
|
collectionNames.rotatedSessionAliases
|
|
433
659
|
);
|
|
434
|
-
this.dpopProofs =
|
|
435
|
-
this.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
|
|
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
|
|
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(
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
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
|
|
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 =
|
|
624
|
+
this.authorizationTransactions = primaryCollection(
|
|
625
|
+
this.db,
|
|
399
626
|
collectionNames.authorizationTransactions
|
|
400
627
|
);
|
|
401
|
-
this.exchangeCodes =
|
|
402
|
-
this.sessions =
|
|
403
|
-
this.backchannelLogoutTokenJtis =
|
|
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 =
|
|
634
|
+
this.rotatedSessionAliases = primaryCollection(
|
|
635
|
+
this.db,
|
|
407
636
|
collectionNames.rotatedSessionAliases
|
|
408
637
|
);
|
|
409
|
-
this.dpopProofs =
|
|
410
|
-
this.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
|
|
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
|
|
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(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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.
|
|
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
|
"mongodb": "^6.20.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|