@web-ts-toolkit/express-oidc-vault-memory-store 0.47.1 → 0.49.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
@@ -16,7 +16,7 @@ pnpm add @web-ts-toolkit/express-oidc-vault @web-ts-toolkit/express-oidc-vault-m
16
16
 
17
17
  ## Production Note
18
18
 
19
- This package stores authorization transactions, exchange codes, sessions, rotated-session aliases, and backchannel logout replay JTIs in process memory.
19
+ This package stores authorization transactions, exchange codes, sessions, rotated-session aliases, backchannel logout replay JTIs, and DPoP proof reservations in process memory.
20
20
 
21
21
  Do not use it for multi-instance or production deployments. Use Redis or MongoDB provider packages for durable or horizontally scaled deployments.
22
22
 
@@ -59,7 +59,7 @@ const storeProvider = createMemoryOidcVaultStore({
59
59
 
60
60
  ## Main Exports
61
61
 
62
- Use named imports from the package root; there is no default export or public subpath API. Use Node.js `>=22.12.0` for the core dependency's CJS/ESM runtime support. TypeScript apps also need `@types/node` and `@types/express`.
62
+ Use named imports from the package root; there is no default export or public subpath API. Use Node.js `>=22.12.0` for the core dependency's CJS/ESM runtime support. TypeScript apps also need `@types/node` and `@types/express`. Both ESM (`import`, `index.d.mts`) and CJS (`require`, `index.d.ts`) declaration conditions are shipped; consumer imports always use the package name.
63
63
 
64
64
  - `createMemoryOidcVaultStore(...)`
65
65
  - `type MemoryOidcVaultStoreOptions`
@@ -93,6 +93,7 @@ The store keeps these record kinds in separate in-process maps:
93
93
  - sessions, read and deleted by session, logical session, subject, or provider session identifiers
94
94
  - rotated-session aliases, used only so an old public session ID can revoke the current logical session
95
95
  - backchannel logout token JTIs, consumed once until their expiry time
96
+ - DPoP proof reservations with their own bounded expiry heap/capacity
96
97
 
97
98
  `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; creating the same key again replaces the old value. A `createSession` replacement takes over the session ID with its own subject/logical/provider-session scope, and clears any stale rotation alias held under that ID so the reused ID cannot invoke an old logical-session meaning. Portable callers must still create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError`: reusing a live ID replaces on memory/MongoDB but rejects on Redis. MongoDB also retains an alias row under a reused ID until expiry or lineage cleanup; only fresh-ID creation is portable.
98
99
 
@@ -111,3 +112,46 @@ Bulk methods return the number of live sessions removed, excluding expired recor
111
112
  ## Replay JTI Expiry
112
113
 
113
114
  Backchannel logout token JTI records are consumed only when `expiresAt` is a finite timestamp greater than the store clock at consume time. Expired, equal-to-current-time, `NaN`, and infinite expiries return `false` and are not stored.
115
+
116
+ ## Device-Binding Store Capabilities
117
+
118
+ `createMemoryOidcVaultStore` returns the core `OidcVaultDeviceBindingStoreProvider`. Its six methods are callable directly from the inferred factory result:
119
+
120
+ All shared types (`OidcVaultDeviceBindingStoreProvider`, `OidcVaultRecordBindingMatch`, guarded consume inputs, `ReserveDpopProofInput`, revocation context and capacity error) are **named root exports of `@web-ts-toolkit/express-oidc-vault`**. This package exports only its factory/options. Either vault `deviceBinding` or `fingerprintRecognition` requires all six capabilities; fingerprint-only flows do not reserve proofs. API middleware uses this store as its explicit replayStore. See the core's shipped README for the complete issuer/request-aware API and browser recipe.
121
+
122
+ - `getAuthorizationTransaction(state)` / `getExchangeCode(code)`: detached live snapshots, without spending records; reads are not locks.
123
+ - `consumeAuthorizationTransactionIfMatches({ state, match })` / `consumeExchangeCodeIfMatches({ code, expectedSessionId, match })`: synchronous expiry/complete-match/deletion before yielding; one winner for concurrent matching consumers.
124
+ - `getSessionRevocationContext(sessionId)`: live handle or unexpired alias resolves its currently live lineage, returning only `logicalSessionId`, allowlisted `provider.issuer/clientId`, and optional `deviceBinding`. No upstream credentials, metadata or profile are returned.
125
+ - `reserveDpopProof({ replayKey, expiresAt })`: process-object-shared atomic replay admission, described below.
126
+
127
+ `match` requires both `{ deviceBinding, browserBindingHash }`; each is an exact value or **null meaning stored absence/undefined**. Null never ignores a field. Records are legacy (neither field), cookie-only (browser hash), or bound (hash + `{ type: 'dpop', jkt }`). Both hashes are canonical 43-character SHA-256 base64url. Null/malformed/extra binding fields and a key without a browser hash are invalid; writes reject, and corrupted stored data cannot yield credentials. Mismatch returns `null` without spending a live record; the honest caller may retry. Original legacy consumes refuse all guarded records, including cookie-only records, without consumption.
128
+
129
+ Session rotation inherits omitted/undefined binding and accepts the same original key; it rejects null/malformed binding, a changed key, or adding a binding to an unbound source before mutation. Session IDs still follow the upsert/alias contracts above. A revocation alias never supplies historical or missing binding as proof of unboundness: all live lineage members must agree on binding/provider identity (malformed/inconsistent authority throws a fixed private diagnostic). Revocation context scans that lineage's candidates in the session map; it does not authenticate aliases through `getSession`.
130
+
131
+ ## Per-Request DPoP Replay And Capacity
132
+
133
+ | Memory option | Default | Contract |
134
+ | ---------------------- | ---------- | -------------------------------------------------------------------------- |
135
+ | `dpopReplayMaxEntries` | `100000` | Positive safe integer, shared by this store object; no live eviction |
136
+ | `now` | `Date.now` | Epoch-millisecond deterministic test clock; proof/store windows must agree |
137
+
138
+ Use one shared store object for every local protection-space instance. Independent memory objects/processes intentionally have independent replay state; use Redis/MongoDB for multi-instance protection. The store reserves opaque keys verbatim; shared orchestration supplies the namespace/key/JTI hash. It does not verify HTTP proofs or build namespaces itself.
139
+
140
+ ```ts
141
+ import { OidcVaultDpopReplayCapacityError } from '@web-ts-toolkit/express-oidc-vault';
142
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
143
+
144
+ const replayStore = createMemoryOidcVaultStore({ dpopReplayMaxEntries: 100_000 });
145
+ // Called only after a proof's signature, target, key and nonce are verified.
146
+ const reserved = await replayStore.reserveDpopProof({
147
+ replayKey: 'opaque-protection-space-key-jti-digest',
148
+ expiresAt: Date.now() + 60_000,
149
+ });
150
+ void [reserved, OidcVaultDpopReplayCapacityError];
151
+ ```
152
+
153
+ `dpopReplayMaxEntries` defaults to **100000**, and must be a positive safe integer. A duplicate returns `false` before capacity checks and never extends expiry. New reservations at capacity throw the core `OidcVaultDpopReplayCapacityError`. Live entries are never evicted. A future safe-integer expiry is required, with remaining TTL **<=360000 ms**; invalid/nonfinite/unsafe/fractional/expired (`<= now()`) or overlong windows return `false` without allocating. Expired keys may be admitted again.
154
+
155
+ Replay uses a Map plus an **indexed min-heap with exactly one node per retained key**, with no unbounded stale-node accumulation or full-map snapshot rebuild. Each admission removes at most **64** expired heap nodes plus a targeted expired duplicate; insertion/removal costs O(log N). It never scans session or alias maps. Expired entries awaiting bounded cleanup may conservatively consume capacity; later new admissions reclaim them. Cleanup runs on traffic, not a timer, and the total retained replay state stays within the configured limit.
156
+
157
+ DPoP reservations are **per request**, much higher-volume than logout JTIs. Size for unique proofs/second × proof-validity window plus headroom (approved defaults: at most 70 seconds retained). All instances in a protection space must use identical windows/namespaces and synchronized clocks. Never release a reservation after a later route failure; retries require a fresh proof. Capacity/provider errors must fail closed and map through orchestration to sanitized replay-unavailable, not proof acceptance. Heap bounds do not imply a fixed latency/throughput guarantee.
package/index.d.mts CHANGED
@@ -1,6 +1,9 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
+ /** Construction options for the named package-root memory-store factory. */
3
4
  interface MemoryOidcVaultStoreOptions {
5
+ /** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
6
+ dpopReplayMaxEntries?: number;
4
7
  /**
5
8
  * Store clock in epoch milliseconds.
6
9
  *
@@ -16,8 +19,11 @@ interface MemoryOidcVaultStoreOptions {
16
19
  * Records are kept in memory, cloned on read/write, cleaned up opportunistically
17
20
  * during store operations, and lost when the Node.js process exits. Do not use
18
21
  * this provider when sessions must survive restarts or be shared by multiple
19
- * application instances.
22
+ * application instances. The returned stronger provider includes guarded
23
+ * exact/null consumes, token-free revocation context, and bounded DPoP replay.
24
+ * Replay is shared only by callers reusing this same object; the store does not
25
+ * verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
20
26
  */
21
- declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultStoreProvider;
27
+ declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
22
28
 
23
29
  export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
package/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
+ /** Construction options for the named package-root memory-store factory. */
3
4
  interface MemoryOidcVaultStoreOptions {
5
+ /** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
6
+ dpopReplayMaxEntries?: number;
4
7
  /**
5
8
  * Store clock in epoch milliseconds.
6
9
  *
@@ -16,8 +19,11 @@ interface MemoryOidcVaultStoreOptions {
16
19
  * Records are kept in memory, cloned on read/write, cleaned up opportunistically
17
20
  * during store operations, and lost when the Node.js process exits. Do not use
18
21
  * this provider when sessions must survive restarts or be shared by multiple
19
- * application instances.
22
+ * application instances. The returned stronger provider includes guarded
23
+ * exact/null consumes, token-free revocation context, and bounded DPoP replay.
24
+ * Replay is shared only by callers reusing this same object; the store does not
25
+ * verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
20
26
  */
21
- declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultStoreProvider;
27
+ declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
22
28
 
23
29
  export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
package/index.js CHANGED
@@ -23,7 +23,122 @@ __export(index_exports, {
23
23
  createMemoryOidcVaultStore: () => createMemoryOidcVaultStore
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
27
+
28
+ // src/binding.ts
26
29
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
30
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
31
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
32
+ var isBinding = (value) => isRecord(value) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
33
+ var hasValidBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
34
+ var hasValidRecordBinding = (record) => hasValidBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
35
+ var assertRecordBinding = (record) => {
36
+ if (!hasValidRecordBinding(record))
37
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
38
+ };
39
+ var assertSessionBinding = (record) => {
40
+ if (!hasValidBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
41
+ };
42
+ var isBindingMatch = (match) => isRecord(match) && Object.hasOwn(match, "deviceBinding") && Object.hasOwn(match, "browserBindingHash") && (match.deviceBinding === null || isBinding(match.deviceBinding)) && (match.browserBindingHash === null || isHash(match.browserBindingHash));
43
+ var matchesRecordBinding = (record, match) => (match.deviceBinding === null ? record.deviceBinding === void 0 : record.deviceBinding?.type === match.deviceBinding.type && record.deviceBinding.jkt === match.deviceBinding.jkt) && (match.browserBindingHash === null ? record.browserBindingHash === void 0 : record.browserBindingHash === match.browserBindingHash);
44
+ var inheritSessionBinding = (source, next) => {
45
+ assertSessionBinding(source);
46
+ assertSessionBinding(next);
47
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
48
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
49
+ }
50
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
51
+ };
52
+ var sessionRevocationContext = (session) => {
53
+ assertSessionBinding(session);
54
+ if (typeof session.sessionId !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string") {
55
+ throw new Error("OIDC vault store lineage has malformed session authority.");
56
+ }
57
+ const provider = session.provider;
58
+ if (provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
59
+ throw new Error("OIDC vault store lineage has malformed provider identity.");
60
+ }
61
+ return {
62
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
63
+ ...provider === void 0 ? {} : {
64
+ provider: {
65
+ ...typeof provider.issuer === "string" ? { issuer: provider.issuer } : {},
66
+ ...typeof provider.clientId === "string" ? { clientId: provider.clientId } : {}
67
+ }
68
+ },
69
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } }
70
+ };
71
+ };
72
+ var hasValidSessionRecord = (session, sessionId) => session.sessionId === sessionId && typeof session.subject === "string" && (session.logicalSessionId === void 0 || typeof session.logicalSessionId === "string") && typeof session.refreshToken === "string" && typeof session.idToken === "string" && (session.expiresAt === void 0 || Number.isFinite(session.expiresAt)) && hasValidBinding(session);
73
+ var assertSameLineageAuthority = (source, member) => {
74
+ assertSessionBinding(member);
75
+ const authority = sessionRevocationContext(member);
76
+ if (source.deviceBinding?.jkt !== authority.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== authority.provider?.issuer || source.provider?.clientId !== authority.provider?.clientId) {
77
+ throw new Error("OIDC vault store lineage has inconsistent device binding or provider identity.");
78
+ }
79
+ };
80
+
81
+ // src/dpop-replay.ts
82
+ var import_express_oidc_vault2 = require("@web-ts-toolkit/express-oidc-vault");
83
+ var DpopReplayReservations = class {
84
+ constructor(maxEntries) {
85
+ this.maxEntries = maxEntries;
86
+ this.reservations = /* @__PURE__ */ new Map();
87
+ this.expiryHeap = [];
88
+ }
89
+ reserve({ replayKey, expiresAt }, now) {
90
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
91
+ if (typeof replayKey !== "string" || !Number.isSafeInteger(expiresAt) || expiresAt <= now || expiresAt - now > 36e4) {
92
+ return false;
93
+ }
94
+ const existing = this.reservations.get(replayKey);
95
+ if (existing && existing.expiresAt > now) return false;
96
+ if (existing) this.remove(existing.index);
97
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.expiresAt <= now; cleaned += 1) this.remove(0);
98
+ if (this.reservations.size >= this.maxEntries) throw new import_express_oidc_vault2.OidcVaultDpopReplayCapacityError();
99
+ const reservation = { replayKey, expiresAt, index: this.expiryHeap.length };
100
+ this.reservations.set(replayKey, reservation);
101
+ this.expiryHeap.push(reservation);
102
+ this.moveUp(reservation.index);
103
+ return true;
104
+ }
105
+ remove(index) {
106
+ const removed = this.expiryHeap[index];
107
+ const last = this.expiryHeap.pop();
108
+ this.reservations.delete(removed.replayKey);
109
+ if (index === this.expiryHeap.length) return;
110
+ this.expiryHeap[index] = last;
111
+ last.index = index;
112
+ this.moveUp(index);
113
+ this.moveDown(last.index);
114
+ }
115
+ swap(left, right) {
116
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
117
+ this.expiryHeap[left].index = left;
118
+ this.expiryHeap[right].index = right;
119
+ }
120
+ moveUp(index) {
121
+ while (index > 0) {
122
+ const parent = Math.floor((index - 1) / 2);
123
+ if (this.expiryHeap[parent].expiresAt <= this.expiryHeap[index].expiresAt) break;
124
+ this.swap(parent, index);
125
+ index = parent;
126
+ }
127
+ }
128
+ moveDown(index) {
129
+ for (; ; ) {
130
+ const left = index * 2 + 1;
131
+ if (left >= this.expiryHeap.length) return;
132
+ const right = left + 1;
133
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].expiresAt < this.expiryHeap[left].expiresAt ? right : left;
134
+ if (this.expiryHeap[index].expiresAt <= this.expiryHeap[child].expiresAt) return;
135
+ this.swap(index, child);
136
+ index = child;
137
+ }
138
+ }
139
+ };
140
+
141
+ // src/index.ts
27
142
  var cloneRecord = (value) => structuredClone(value);
28
143
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
29
144
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
@@ -60,50 +175,88 @@ var MemoryOidcVaultStore = class {
60
175
  this.rotatedSessionAliasSweep = createMapSweepState();
61
176
  this.backchannelLogoutTokenJtiSweep = createMapSweepState();
62
177
  this.sweepWork = createMemoryStoreSweepWork();
178
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
179
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
180
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
181
+ }
182
+ this.dpopProofs = new DpopReplayReservations(maxEntries);
63
183
  this.now = options.now ?? (() => Date.now());
64
184
  }
65
185
  async createAuthorizationTransaction(input) {
186
+ input = cloneRecord(input);
187
+ assertRecordBinding(input);
66
188
  const now = this.now();
67
189
  this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
68
- this.authorizationTransactions.set(input.state, cloneRecord(input));
190
+ this.authorizationTransactions.set(input.state, input);
69
191
  }
70
192
  async consumeAuthorizationTransaction(state) {
193
+ return this.consumeAuthorizationTransactionIfMatches({
194
+ state,
195
+ match: { deviceBinding: null, browserBindingHash: null }
196
+ });
197
+ }
198
+ async getAuthorizationTransaction(state) {
199
+ const record = this.readOneTimeRecord(this.authorizationTransactions, state, this.now());
200
+ return record && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" ? cloneRecord(record) : null;
201
+ }
202
+ async consumeAuthorizationTransactionIfMatches(input) {
203
+ input = cloneRecord(input);
204
+ if (!isBindingMatch(input.match)) return null;
71
205
  const now = this.now();
72
206
  this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
73
- const record = this.authorizationTransactions.get(state);
74
- if (!record) {
75
- return null;
76
- }
77
- this.authorizationTransactions.delete(state);
78
- if (isExpiredRecord(record, now)) {
207
+ const record = this.readOneTimeRecord(this.authorizationTransactions, input.state, now);
208
+ if (!record || record.state !== input.state || typeof record.nonce !== "string" || typeof record.pkceVerifier !== "string" || typeof record.codeChallenge !== "string" || !matchesRecordBinding(record, input.match))
79
209
  return null;
80
- }
210
+ this.authorizationTransactions.delete(input.state);
81
211
  return cloneRecord(record);
82
212
  }
83
213
  async createExchangeCode(input) {
214
+ input = cloneRecord(input);
215
+ assertRecordBinding(input);
84
216
  const now = this.now();
85
217
  this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
86
- this.exchangeCodes.set(input.code, cloneRecord(input));
218
+ this.exchangeCodes.set(input.code, input);
87
219
  }
88
220
  async consumeExchangeCode(code) {
89
221
  const now = this.now();
90
222
  this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
91
- const record = this.exchangeCodes.get(code);
92
- if (!record) {
223
+ return this.consumeExchangeRecord(code, { deviceBinding: null, browserBindingHash: null }, now);
224
+ }
225
+ async getExchangeCode(code) {
226
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, this.now());
227
+ return record && record.code === code && typeof record.sessionId === "string" ? cloneRecord(record) : null;
228
+ }
229
+ async consumeExchangeCodeIfMatches(input) {
230
+ input = cloneRecord(input);
231
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
232
+ const now = this.now();
233
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
234
+ return this.consumeExchangeRecord(input.code, input.match, now, input.expectedSessionId);
235
+ }
236
+ consumeExchangeRecord(code, match, now, expectedSessionId) {
237
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, now);
238
+ if (!record || record.code !== code || typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || !matchesRecordBinding(record, match))
93
239
  return null;
94
- }
95
240
  this.exchangeCodes.delete(code);
96
- if (isExpiredRecord(record, now)) {
241
+ return cloneRecord(record);
242
+ }
243
+ readOneTimeRecord(map, id, now) {
244
+ const record = map.get(id);
245
+ if (!record) return null;
246
+ if (!Number.isFinite(record.createdAt) || !Number.isFinite(record.expiresAt) || isExpiredRecord(record, now) || !hasValidRecordBinding(record)) {
247
+ map.delete(id);
97
248
  return null;
98
249
  }
99
- return cloneRecord(record);
250
+ return record;
100
251
  }
101
252
  async createSession(input) {
253
+ input = cloneRecord(input);
254
+ assertSessionBinding(input);
102
255
  const timestamp = this.now();
103
256
  this.pruneSessionsBatch(timestamp);
104
257
  this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
105
258
  const session = {
106
- ...cloneRecord(input),
259
+ ...input,
107
260
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
108
261
  createdAt: input.createdAt ?? timestamp,
109
262
  updatedAt: input.updatedAt ?? timestamp
@@ -125,7 +278,37 @@ var MemoryOidcVaultStore = class {
125
278
  this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
126
279
  return null;
127
280
  }
128
- return session ? cloneRecord(session) : null;
281
+ return session && hasValidSessionRecord(session, sessionId) ? cloneRecord(session) : null;
282
+ }
283
+ async getSessionRevocationContext(sessionId) {
284
+ const now = this.now();
285
+ const live = this.sessions.get(sessionId);
286
+ const alias = this.rotatedSessionAliases.get(sessionId);
287
+ if (live && !isExpiredRecord(live, now)) {
288
+ if (live.sessionId !== sessionId) throw new Error("OIDC vault store has malformed revocation authority.");
289
+ sessionRevocationContext(live);
290
+ }
291
+ if ((!live || isExpiredRecord(live, now)) && alias && (typeof alias.logicalSessionId !== "string" || alias.expiresAt !== void 0 && !Number.isFinite(alias.expiresAt))) {
292
+ throw new Error("OIDC vault store has malformed revocation alias.");
293
+ }
294
+ const logicalSessionId = live && !isExpiredRecord(live, now) ? live.logicalSessionId ?? live.sessionId : alias && !isExpiredRecord(alias, now) ? alias.logicalSessionId : void 0;
295
+ if (logicalSessionId === void 0) return null;
296
+ if (typeof logicalSessionId !== "string") throw new Error("OIDC vault store has malformed revocation authority.");
297
+ let source;
298
+ for (const member of this.sessions.values()) {
299
+ if ((member.logicalSessionId ?? member.sessionId) !== logicalSessionId || isExpiredRecord(member, now)) continue;
300
+ if (member.sessionId === void 0 || typeof member.subject !== "string" || member.expiresAt !== void 0 && !Number.isFinite(member.expiresAt)) {
301
+ throw new Error("OIDC vault store lineage has malformed session authority.");
302
+ }
303
+ sessionRevocationContext(member);
304
+ if (source) assertSameLineageAuthority(source, member);
305
+ else source = member;
306
+ }
307
+ return source ? sessionRevocationContext(source) : null;
308
+ }
309
+ async reserveDpopProof(input) {
310
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
311
+ return this.dpopProofs.reserve(input, this.now());
129
312
  }
130
313
  async rotateSession(input) {
131
314
  const timestamp = this.now();
@@ -135,17 +318,18 @@ var MemoryOidcVaultStore = class {
135
318
  this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
136
319
  }
137
320
  const liveSourceSession = this.sessions.get(input.sessionId);
138
- if (!liveSourceSession) {
139
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
321
+ if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
322
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
140
323
  }
141
324
  if (input.nextSession.sessionId === input.sessionId) {
142
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
325
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
143
326
  }
144
327
  const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
145
328
  if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
146
- throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
329
+ throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
147
330
  }
148
331
  const nextSession = cloneRecord(input.nextSession);
332
+ inheritSessionBinding(liveSourceSession, nextSession);
149
333
  const session = {
150
334
  ...nextSession,
151
335
  logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
package/index.mjs CHANGED
@@ -1,7 +1,122 @@
1
1
  // src/index.ts
2
2
  import {
3
- OidcVaultStoreConflictError
3
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError2
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
+
6
+ // src/binding.ts
7
+ import { OidcVaultStoreConflictError } from "@web-ts-toolkit/express-oidc-vault";
8
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
9
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
10
+ var isBinding = (value) => isRecord(value) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
11
+ var hasValidBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
12
+ var hasValidRecordBinding = (record) => hasValidBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
13
+ var assertRecordBinding = (record) => {
14
+ if (!hasValidRecordBinding(record))
15
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
16
+ };
17
+ var assertSessionBinding = (record) => {
18
+ if (!hasValidBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
19
+ };
20
+ var isBindingMatch = (match) => isRecord(match) && Object.hasOwn(match, "deviceBinding") && Object.hasOwn(match, "browserBindingHash") && (match.deviceBinding === null || isBinding(match.deviceBinding)) && (match.browserBindingHash === null || isHash(match.browserBindingHash));
21
+ var matchesRecordBinding = (record, match) => (match.deviceBinding === null ? record.deviceBinding === void 0 : record.deviceBinding?.type === match.deviceBinding.type && record.deviceBinding.jkt === match.deviceBinding.jkt) && (match.browserBindingHash === null ? record.browserBindingHash === void 0 : record.browserBindingHash === match.browserBindingHash);
22
+ var inheritSessionBinding = (source, next) => {
23
+ assertSessionBinding(source);
24
+ assertSessionBinding(next);
25
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
26
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
27
+ }
28
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
29
+ };
30
+ var sessionRevocationContext = (session) => {
31
+ assertSessionBinding(session);
32
+ if (typeof session.sessionId !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string") {
33
+ throw new Error("OIDC vault store lineage has malformed session authority.");
34
+ }
35
+ const provider = session.provider;
36
+ if (provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
37
+ throw new Error("OIDC vault store lineage has malformed provider identity.");
38
+ }
39
+ return {
40
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
41
+ ...provider === void 0 ? {} : {
42
+ provider: {
43
+ ...typeof provider.issuer === "string" ? { issuer: provider.issuer } : {},
44
+ ...typeof provider.clientId === "string" ? { clientId: provider.clientId } : {}
45
+ }
46
+ },
47
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } }
48
+ };
49
+ };
50
+ var hasValidSessionRecord = (session, sessionId) => session.sessionId === sessionId && typeof session.subject === "string" && (session.logicalSessionId === void 0 || typeof session.logicalSessionId === "string") && typeof session.refreshToken === "string" && typeof session.idToken === "string" && (session.expiresAt === void 0 || Number.isFinite(session.expiresAt)) && hasValidBinding(session);
51
+ var assertSameLineageAuthority = (source, member) => {
52
+ assertSessionBinding(member);
53
+ const authority = sessionRevocationContext(member);
54
+ if (source.deviceBinding?.jkt !== authority.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== authority.provider?.issuer || source.provider?.clientId !== authority.provider?.clientId) {
55
+ throw new Error("OIDC vault store lineage has inconsistent device binding or provider identity.");
56
+ }
57
+ };
58
+
59
+ // src/dpop-replay.ts
60
+ import { OidcVaultDpopReplayCapacityError } from "@web-ts-toolkit/express-oidc-vault";
61
+ var DpopReplayReservations = class {
62
+ constructor(maxEntries) {
63
+ this.maxEntries = maxEntries;
64
+ this.reservations = /* @__PURE__ */ new Map();
65
+ this.expiryHeap = [];
66
+ }
67
+ reserve({ replayKey, expiresAt }, now) {
68
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
69
+ if (typeof replayKey !== "string" || !Number.isSafeInteger(expiresAt) || expiresAt <= now || expiresAt - now > 36e4) {
70
+ return false;
71
+ }
72
+ const existing = this.reservations.get(replayKey);
73
+ if (existing && existing.expiresAt > now) return false;
74
+ if (existing) this.remove(existing.index);
75
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.expiresAt <= now; cleaned += 1) this.remove(0);
76
+ if (this.reservations.size >= this.maxEntries) throw new OidcVaultDpopReplayCapacityError();
77
+ const reservation = { replayKey, expiresAt, index: this.expiryHeap.length };
78
+ this.reservations.set(replayKey, reservation);
79
+ this.expiryHeap.push(reservation);
80
+ this.moveUp(reservation.index);
81
+ return true;
82
+ }
83
+ remove(index) {
84
+ const removed = this.expiryHeap[index];
85
+ const last = this.expiryHeap.pop();
86
+ this.reservations.delete(removed.replayKey);
87
+ if (index === this.expiryHeap.length) return;
88
+ this.expiryHeap[index] = last;
89
+ last.index = index;
90
+ this.moveUp(index);
91
+ this.moveDown(last.index);
92
+ }
93
+ swap(left, right) {
94
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
95
+ this.expiryHeap[left].index = left;
96
+ this.expiryHeap[right].index = right;
97
+ }
98
+ moveUp(index) {
99
+ while (index > 0) {
100
+ const parent = Math.floor((index - 1) / 2);
101
+ if (this.expiryHeap[parent].expiresAt <= this.expiryHeap[index].expiresAt) break;
102
+ this.swap(parent, index);
103
+ index = parent;
104
+ }
105
+ }
106
+ moveDown(index) {
107
+ for (; ; ) {
108
+ const left = index * 2 + 1;
109
+ if (left >= this.expiryHeap.length) return;
110
+ const right = left + 1;
111
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].expiresAt < this.expiryHeap[left].expiresAt ? right : left;
112
+ if (this.expiryHeap[index].expiresAt <= this.expiryHeap[child].expiresAt) return;
113
+ this.swap(index, child);
114
+ index = child;
115
+ }
116
+ }
117
+ };
118
+
119
+ // src/index.ts
5
120
  var cloneRecord = (value) => structuredClone(value);
6
121
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
7
122
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
@@ -38,50 +153,88 @@ var MemoryOidcVaultStore = class {
38
153
  this.rotatedSessionAliasSweep = createMapSweepState();
39
154
  this.backchannelLogoutTokenJtiSweep = createMapSweepState();
40
155
  this.sweepWork = createMemoryStoreSweepWork();
156
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
157
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
158
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
159
+ }
160
+ this.dpopProofs = new DpopReplayReservations(maxEntries);
41
161
  this.now = options.now ?? (() => Date.now());
42
162
  }
43
163
  async createAuthorizationTransaction(input) {
164
+ input = cloneRecord(input);
165
+ assertRecordBinding(input);
44
166
  const now = this.now();
45
167
  this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
46
- this.authorizationTransactions.set(input.state, cloneRecord(input));
168
+ this.authorizationTransactions.set(input.state, input);
47
169
  }
48
170
  async consumeAuthorizationTransaction(state) {
171
+ return this.consumeAuthorizationTransactionIfMatches({
172
+ state,
173
+ match: { deviceBinding: null, browserBindingHash: null }
174
+ });
175
+ }
176
+ async getAuthorizationTransaction(state) {
177
+ const record = this.readOneTimeRecord(this.authorizationTransactions, state, this.now());
178
+ return record && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" ? cloneRecord(record) : null;
179
+ }
180
+ async consumeAuthorizationTransactionIfMatches(input) {
181
+ input = cloneRecord(input);
182
+ if (!isBindingMatch(input.match)) return null;
49
183
  const now = this.now();
50
184
  this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
51
- const record = this.authorizationTransactions.get(state);
52
- if (!record) {
185
+ const record = this.readOneTimeRecord(this.authorizationTransactions, input.state, now);
186
+ if (!record || record.state !== input.state || typeof record.nonce !== "string" || typeof record.pkceVerifier !== "string" || typeof record.codeChallenge !== "string" || !matchesRecordBinding(record, input.match))
53
187
  return null;
54
- }
55
- this.authorizationTransactions.delete(state);
56
- if (isExpiredRecord(record, now)) {
57
- return null;
58
- }
188
+ this.authorizationTransactions.delete(input.state);
59
189
  return cloneRecord(record);
60
190
  }
61
191
  async createExchangeCode(input) {
192
+ input = cloneRecord(input);
193
+ assertRecordBinding(input);
62
194
  const now = this.now();
63
195
  this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
64
- this.exchangeCodes.set(input.code, cloneRecord(input));
196
+ this.exchangeCodes.set(input.code, input);
65
197
  }
66
198
  async consumeExchangeCode(code) {
67
199
  const now = this.now();
68
200
  this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
69
- const record = this.exchangeCodes.get(code);
70
- if (!record) {
201
+ return this.consumeExchangeRecord(code, { deviceBinding: null, browserBindingHash: null }, now);
202
+ }
203
+ async getExchangeCode(code) {
204
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, this.now());
205
+ return record && record.code === code && typeof record.sessionId === "string" ? cloneRecord(record) : null;
206
+ }
207
+ async consumeExchangeCodeIfMatches(input) {
208
+ input = cloneRecord(input);
209
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
210
+ const now = this.now();
211
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
212
+ return this.consumeExchangeRecord(input.code, input.match, now, input.expectedSessionId);
213
+ }
214
+ consumeExchangeRecord(code, match, now, expectedSessionId) {
215
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, now);
216
+ if (!record || record.code !== code || typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || !matchesRecordBinding(record, match))
71
217
  return null;
72
- }
73
218
  this.exchangeCodes.delete(code);
74
- if (isExpiredRecord(record, now)) {
219
+ return cloneRecord(record);
220
+ }
221
+ readOneTimeRecord(map, id, now) {
222
+ const record = map.get(id);
223
+ if (!record) return null;
224
+ if (!Number.isFinite(record.createdAt) || !Number.isFinite(record.expiresAt) || isExpiredRecord(record, now) || !hasValidRecordBinding(record)) {
225
+ map.delete(id);
75
226
  return null;
76
227
  }
77
- return cloneRecord(record);
228
+ return record;
78
229
  }
79
230
  async createSession(input) {
231
+ input = cloneRecord(input);
232
+ assertSessionBinding(input);
80
233
  const timestamp = this.now();
81
234
  this.pruneSessionsBatch(timestamp);
82
235
  this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
83
236
  const session = {
84
- ...cloneRecord(input),
237
+ ...input,
85
238
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
86
239
  createdAt: input.createdAt ?? timestamp,
87
240
  updatedAt: input.updatedAt ?? timestamp
@@ -103,7 +256,37 @@ var MemoryOidcVaultStore = class {
103
256
  this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
104
257
  return null;
105
258
  }
106
- return session ? cloneRecord(session) : null;
259
+ return session && hasValidSessionRecord(session, sessionId) ? cloneRecord(session) : null;
260
+ }
261
+ async getSessionRevocationContext(sessionId) {
262
+ const now = this.now();
263
+ const live = this.sessions.get(sessionId);
264
+ const alias = this.rotatedSessionAliases.get(sessionId);
265
+ if (live && !isExpiredRecord(live, now)) {
266
+ if (live.sessionId !== sessionId) throw new Error("OIDC vault store has malformed revocation authority.");
267
+ sessionRevocationContext(live);
268
+ }
269
+ if ((!live || isExpiredRecord(live, now)) && alias && (typeof alias.logicalSessionId !== "string" || alias.expiresAt !== void 0 && !Number.isFinite(alias.expiresAt))) {
270
+ throw new Error("OIDC vault store has malformed revocation alias.");
271
+ }
272
+ const logicalSessionId = live && !isExpiredRecord(live, now) ? live.logicalSessionId ?? live.sessionId : alias && !isExpiredRecord(alias, now) ? alias.logicalSessionId : void 0;
273
+ if (logicalSessionId === void 0) return null;
274
+ if (typeof logicalSessionId !== "string") throw new Error("OIDC vault store has malformed revocation authority.");
275
+ let source;
276
+ for (const member of this.sessions.values()) {
277
+ if ((member.logicalSessionId ?? member.sessionId) !== logicalSessionId || isExpiredRecord(member, now)) continue;
278
+ if (member.sessionId === void 0 || typeof member.subject !== "string" || member.expiresAt !== void 0 && !Number.isFinite(member.expiresAt)) {
279
+ throw new Error("OIDC vault store lineage has malformed session authority.");
280
+ }
281
+ sessionRevocationContext(member);
282
+ if (source) assertSameLineageAuthority(source, member);
283
+ else source = member;
284
+ }
285
+ return source ? sessionRevocationContext(source) : null;
286
+ }
287
+ async reserveDpopProof(input) {
288
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
289
+ return this.dpopProofs.reserve(input, this.now());
107
290
  }
108
291
  async rotateSession(input) {
109
292
  const timestamp = this.now();
@@ -113,17 +296,18 @@ var MemoryOidcVaultStore = class {
113
296
  this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
114
297
  }
115
298
  const liveSourceSession = this.sessions.get(input.sessionId);
116
- if (!liveSourceSession) {
117
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
299
+ if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
300
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
118
301
  }
119
302
  if (input.nextSession.sessionId === input.sessionId) {
120
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
303
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
121
304
  }
122
305
  const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
123
306
  if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
124
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
307
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
125
308
  }
126
309
  const nextSession = cloneRecord(input.nextSession);
310
+ inheritSessionBinding(liveSourceSession, nextSession);
127
311
  const session = {
128
312
  ...nextSession,
129
313
  logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-memory-store",
3
3
  "description": "In-memory store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-memory-store",
5
- "version": "0.47.1",
5
+ "version": "0.49.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -26,10 +26,10 @@
26
26
  }
27
27
  },
28
28
  "engines": {
29
- "node": ">=22"
29
+ "node": ">=22.12.0"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.47.1"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.49.0"
33
33
  },
34
34
  "files": [
35
35
  "**/*",