@web-ts-toolkit/express-oidc-vault-redis-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
@@ -99,9 +99,9 @@ Bulk deletion makes one cursor traversal, rechecks scope in each atomic delete s
99
99
 
100
100
  ## Redis Version And Topology
101
101
 
102
- - **Minimum Redis version: 6.2.** One-time authorization transactions and exchange codes are consumed with `GETDEL`, which is unavailable before Redis 6.2. Versions 6.2 and 7.2 are exercised in integration tests.
102
+ - **Supported minimum Redis version: 6.2.** Versions 6.2 and 7.2 are exercised in integration tests. One-time consumption now uses guarded cached Lua, including legacy consumes, so old calls cannot spend guarded records.
103
103
  - **Standalone Redis**: supported through the official `redis` `createClient(...)` client (`RedisClientType`). This is the tested topological default.
104
- - **Redis Sentinel**: supported by passing the underlying master client retrieved from a `redis.createSentinel(...)` sentinel — for example, the client returned by `await sentinel.acquire()`, or via `await sentinel.use(c => c)` patterns documented in the `redis` package. The bare `createSentinel(...)` root client does NOT satisfy this package's structural contract directly: its `sendCommand(isReadonly, args, options?)` requires an `isReadonly` first argument, while this store calls `sendCommand(args)`. Wrap the underlying master with an adapter conforming to `OidcVaultRedisClient` if you want to retain the Sentinel's connection management. Sentinel failover switches the underlying node, so vault state survives a failover subject to your Sentinel AOF/RDB durability settings.
104
+ - **Redis Sentinel**: use an underlying master client retrieved from `redis.createSentinel(...)` (e.g. acquire/use patterns documented by that driver) or an adapter conforming to `OidcVaultRedisClient`. The bare Sentinel root has `sendCommand(isReadonly, args, options?)`, not this store's `sendCommand(args)`. Caller/adapter owns master acquisition, reconnect/failover and release. Automated provider tests exercise standalone 6.2/7.2, not Sentinel failover; replication/persistence guarantees are deployment-owned, and losing unexpired replay reservations during failover weakens replay protection until their window expires.
105
105
  - **Redis Cluster**: **not supported.** Cluster-shaped official clients are rejected when the store is created. The package's atomic vault scripts touch several keys without a hash-slot routing adapter, so Cluster routing could send parts of one logical operation to different nodes. Add a Cluster routing/hash-slot adapter before enabling it.
106
106
 
107
107
  ## Client Lifecycle And Ownership
@@ -127,8 +127,8 @@ Bulk deletion makes one cursor traversal, rechecks scope in each atomic delete s
127
127
  ## Expiry And Index Cleanup
128
128
 
129
129
  - expiring session records are stored with Redis `PXAT`, and revocation index scores use the same absolute expiration timestamp
130
- - authorization transactions and exchange codes also use `PXAT`. Redis key expiry is authoritative for reads/consumes; payload `expiresAt` is structurally validated but is not independently compared to a clock on each read. Valid single-record reads use one `GET` or `GETDEL`, without a separate `TIME` round trip, preserving the split-clock policy above
131
- - **TTL integrity assumption:** preserve the store-written key TTLs and matching index scores when restoring or migrating data. Externally removing/extending a TTL can make a past-dated payload readable/consumable; changing only a session TTL can also leave it live after its index membership is pruned. Read validation does not audit or repair this external expiry inconsistency. Shortening a key TTL makes the record unavailable as soon as Redis expires it, even if its payload expiry is later
130
+ - authorization transactions and exchange codes also use `PXAT`. Legacy consumes and `getSession` preserve the key-TTL policy (no application-clock expiry check); legacy consumption uses one cached Lua call. The new transaction/code preflight getters compare payload expiry with Redis `TIME` (normally `GET` + `TIME`); guarded consumes compare payload expiry inside Lua with server time. They reject expired payloads even after externally altered TTLs
131
+ - **TTL integrity assumption:** preserve the store-written key TTLs and matching index scores when restoring or migrating data. Externally removing/extending a TTL can still expose past-dated payloads through legacy consumes or `getSession`; changing only a session TTL can leave it live after index pruning. New guarded operations additionally enforce payload expiry, but do not repair external index/TTL inconsistencies. Shortening a key TTL makes a record unavailable immediately even when payload expiry is later
132
132
  - stale revocation memberships are pruned with Redis `TIME`, not the application clock, so a skewed application clock cannot remove a still-live Redis session from subject, provider-session, or logical-session indexes
133
133
  - indexed revocation also checks the primary session key before deleting or returning a session; expired primary values are not returned and missing primary values remove the stale membership encountered during traversal
134
134
  - indexed revocation uses `ZSCAN COUNT 250` and one `MGET` for the IDs returned in each batch. `COUNT` is a hint, not a hard batch cap (compact sorted sets can return all members); the batch is not further chunked. Response memory and repair concurrency can grow with the returned batch, while deletion scripts run sequentially
@@ -144,16 +144,55 @@ Bulk deletion makes one cursor traversal, rechecks scope in each atomic delete s
144
144
  - malformed one-time authorization transaction and exchange-code records are consumed atomically and return `null`, preventing reuse while failing closed
145
145
  - malformed session records are treated as unreadable, deleted when encountered through session reads or indexed revocation, and never returned as authenticated state
146
146
  - corruption repair deletes only the observed malformed payload through an atomic server-side compare-and-delete: a fresh same-ID record created after the stale read (session ID reuse is supported) is never destroyed by the repair, and the missing-session logout path applies the same guard instead of an unconditional delete
147
- - **Identity-validation compatibility note:** shape-valid records stored under the wrong identity now follow the malformed-record policy and return `null`. Session repair targets only the observed lookup key, never the embedded session ID; indexed membership repair retains the same generation guard. One-time mismatches are consumed by the original atomic `GETDEL`, with no follow-up delete that could remove a replacement
147
+ - **Identity-validation compatibility note:** shape-valid records stored under the wrong identity follow the malformed-record policy and return `null`. Session repair targets only the observed lookup key, never the embedded session ID; indexed membership repair retains the same generation guard. One-time corruption is removed inside the same atomic consume script, with no follow-up delete that could remove a replacement
148
148
  - a stale repair observing corruption therefore preserves a concurrent replacement, while genuine logout of a live session (direct delete or scoped logical/subject/provider-session revocation) still deletes it; indexed revocation removes stale corrupt members and continues processing later valid sessions
149
149
  - Redis mutation scripts preflight expected key types before writing so wrong-type keys fail without deterministic partial mutation
150
150
  - package-generated validation and deterministic script diagnostics use fixed text without stored records. Arbitrary client/adapter failures can still reject operations with sensitive original errors; they are not generally sanitized for application logs. Use fixed operation names and allowlisted categories, not raw errors, connection URLs, session IDs, state/PKCE values, exchange codes or token-valued metric labels. The secret-independent post-commit warning policy above applies specifically to that warning path
151
151
 
152
152
  ## Main Exports
153
153
 
154
- 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`.
154
+ 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.
155
155
 
156
156
  - `createRedisOidcVaultStore(...)`
157
157
  - `OidcVaultRedisStoreRecordError`
158
158
  - `type RedisOidcVaultStoreOptions`
159
159
  - `type OidcVaultRedisClient`
160
+
161
+ ## Device-Binding Store Capabilities
162
+
163
+ The factory returns core `OidcVaultDeviceBindingStoreProvider`, with detached live `getAuthorizationTransaction(state)` and `getExchangeCode(code)`, atomic `consumeAuthorizationTransactionIfMatches({ state, match })` and `consumeExchangeCodeIfMatches({ code, expectedSessionId, match })`, token-free `getSessionRevocationContext(sessionId)`, and `reserveDpopProof({ replayKey, expiresAt })`.
164
+
165
+ Import the stronger provider, match/consume/replay/revocation types and `OidcVaultDpopReplayCapacityError` from the **core package root**; the Redis package exports its factory/options/client and record error only. Either vault opt-in feature requires all six capabilities, while fingerprint-only flows do not reserve proofs. Use the same shared provider as API replayStore; the core shipped README includes complete DPoP issuer/API/browser usage.
166
+
167
+ `match` requires both `deviceBinding` and `browserBindingHash`. Non-null values match exactly; **null requires stored absence**, never BSON/JSON null or “ignore”. Legacy records omit both; guarded unbound records have a cookie hash; bound records have that hash plus `{ type: 'dpop', jkt }`. Hashes are canonical 43-character SHA-256 base64url. Null/malformed/extra binding fields and key-without-hash records are invalid; writes reject and corrupted data returns no credentials. No JWK, proof algorithm, or historical mode is stored.
168
+
169
+ Preflight getters are not locks. The consume script performs shape/identity/expiry, both matches, exchange-session match and deletion atomically; mismatch returns `null` without spending a live record. Matching races have one winner across independent Redis clients. **Legacy consumes now also use this script** and refuse guarded records, including cookie-only records, without spending them. There is no application-side get/unconditional-delete bypass. Malformed read cleanup retains the existing compare-and-delete generation guard.
170
+
171
+ Rotation inherits omitted binding, accepts the same key, and rejects null/malformed, changed, or newly added binding before mutation. Lua also rechecks source identity, credential generation and binding against the preflight source so same-ID replacement cannot make a new lineage inherit an old key or lose index ownership. Target-ID, alias, deletion and post-commit maintenance policies remain as documented above.
172
+
173
+ `getSessionRevocationContext` resolves a live handle or unexpired alias and all live members of its current lineage inside one Lua snapshot. A directly observed live handle always participates even if its reverse-index membership is missing, so peers cannot make that bound handle appear unbound. It returns only `logicalSessionId`, allowlisted `provider.issuer/clientId`, and optional original `deviceBinding`; no tokens/profile/metadata/arbitrary provider fields leave Redis. Alias payloads still contain only their logical ID: absence of an alias binding never means an unbound lineage. Malformed/inconsistent binding/provider authority throws a fixed diagnostic. Expired aliases/empty lineages return `null`, and `getSession(alias)` remains `null`. This call materializes the whole logical-member set and reads member primaries inside Lua; large lineages remain scale-dependent server work.
174
+
175
+ ## Per-Request DPoP Replay And Capacity
176
+
177
+ | Redis option | Default | Contract |
178
+ | ---------------------- | ------------ | ----------------------------------------------------------------------- |
179
+ | `client` | required | Connected standalone-shaped client/adapter; caller owns lifecycle |
180
+ | `keyPrefix` | `oidc-vault` | Shared vault/replay namespace; changing it is not migration |
181
+ | `dpopReplayMaxEntries` | `100000` | Positive safe integer, identical on clients sharing the prefix |
182
+ | `now` | `Date.now` | Store timestamps/backchannel preflight only; replay/TTL use server TIME |
183
+
184
+ ```ts
185
+ const storeProvider = createRedisOidcVaultStore({
186
+ client: redis,
187
+ keyPrefix: 'oidc-vault',
188
+ dpopReplayMaxEntries: 100_000,
189
+ });
190
+ ```
191
+
192
+ `dpopReplayMaxEntries` is a positive safe integer (default **100000**), shared per key prefix across clients. Every client sharing that prefix must configure the same limit. The opaque proof keys are members of **`<keyPrefix>:dpop-proofs`**, a sorted set whose scores are absolute expiry milliseconds. This set is both replay state and capacity/expiry accounting; there is no separate counter that can leak after individual key TTLs.
193
+
194
+ `reserveDpopProof` is normally **one cached `EVALSHA` round trip per proof**. Server `TIME` inside Lua owns the validity window; the optional application `now` hook cannot admit a stale proof or prune live reservations. A safe-integer future expiry is required, with remaining TTL **<=360000 ms**. Invalid/nonfinite/unsafe/fractional/expired (`<= server now`) or overlong input returns `false` without allocation. Duplicate-first `ZSCORE` rejects without extending expiry, even at capacity. Lua reclaims at most **64** expired members with `ZRANGEBYSCORE ... LIMIT 0 64`, plus the requested expired key, then atomically checks `ZCARD` and admits with `ZADD`. No session/alias scan, unbounded `ZREMRANGEBYSCORE`, or live-entry eviction occurs on replay admission.
195
+
196
+ The replay set expires at its latest retained score (`PEXPIREAT`), reclaiming it if traffic stops; this deadline never extends a member's replay window. Expired members awaiting bounded cleanup may conservatively occupy capacity. New keys at capacity throw root `OidcVaultDpopReplayCapacityError` from `@web-ts-toolkit/express-oidc-vault`, with no live eviction or acceptance without replay state. Wrong-type/client failures reject. `NOSCRIPT` recovery loads and retries once, adding two round trips to the normal call; the existing runner never retries an arbitrary committed mutation on transport failure.
197
+
198
+ Provision ACLs for cached scripts and their internal commands, including `TIME`, `TYPE`, `GET`, `DEL`, `ZSCORE`, `ZRANGEBYSCORE`, `ZREM`, `ZCARD`, `ZADD`, `ZREVRANGE` and `PEXPIREAT`. DPoP is a **per-request write**, unlike lower-volume backchannel JTIs (`SET NX PXAT`). Size capacity for unique proofs/second × validity window plus headroom (approved defaults: at most 70 seconds). Share identical namespaces/windows and synchronized clocks across instances; never release reservations after downstream failure, and retries need a fresh proof. Capacity/provider errors must map through orchestration to sanitized replay-unavailable and fail closed. Redis failover durability remains deployment-owned; these command/work bounds are not throughput/latency guarantees.
package/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /**
4
4
  * Thrown when a stored Redis value cannot be parsed or fails structural
@@ -34,7 +34,7 @@ declare class OidcVaultRedisStoreRecordError extends Error {
34
34
  *
35
35
  * `sendCommand` is required because atomic session writes, rotations,
36
36
  * deletions, and one-time record consumption transit Redis
37
- * `EVAL`/`GETDEL`/`TYPE`/`TIME`/`ZRANGE`/`ZSCAN`/`MGET` commands through it.
37
+ * cached Lua/`TYPE`/`TIME`/`ZRANGE`/`ZSCAN`/`MGET` commands through it.
38
38
  * It was previously optional in this type but always enforced at runtime.
39
39
  */
40
40
  interface OidcVaultRedisClient {
@@ -59,6 +59,8 @@ interface OidcVaultRedisClient {
59
59
  * `disconnect()`). The package never calls `connect`, `quit`, or `disconnect`.
60
60
  */
61
61
  interface RedisOidcVaultStoreOptions {
62
+ /** Positive safe integer per shared keyPrefix; default 100000. Configure identically on all clients. */
63
+ dpopReplayMaxEntries?: number;
62
64
  /** Connected Redis client or compatible adapter implementing {@link OidcVaultRedisClient}. */
63
65
  client: OidcVaultRedisClient;
64
66
  /**
@@ -77,7 +79,7 @@ interface RedisOidcVaultStoreOptions {
77
79
  now?: () => number;
78
80
  }
79
81
  /**
80
- * Create an `OidcVaultStoreProvider` backed by a connected Redis client.
82
+ * Create an `OidcVaultDeviceBindingStoreProvider` backed by a connected Redis client.
81
83
  *
82
84
  * Pass an already-connected official `redis` standalone client (or any adapter
83
85
  * implementing {@link OidcVaultRedisClient}). For Redis Sentinel deployments,
@@ -86,12 +88,14 @@ interface RedisOidcVaultStoreOptions {
86
88
  * satisfy the structural contract. The store does not connect, disconnect, or
87
89
  * attach `error` listeners; it only issues commands.
88
90
  *
89
- * The package requires Redis 6.2 or later (it uses `GETDEL`). Redis Cluster is
90
- * not supported and is rejected here.
91
+ * The package supports Redis 6.2 or later. Redis Cluster is
92
+ * not supported and is rejected here. Guarded consumes and per-proof replay use
93
+ * cached atomic Lua; replay is shared per keyPrefix, uses server TIME, and never
94
+ * evicts live entries. All clients must use the same dpopReplayMaxEntries.
91
95
  *
92
96
  * @param options construction options; see {@link RedisOidcVaultStoreOptions}.
93
- * @returns a provider satisfying the core `OidcVaultStoreProvider` contract.
97
+ * @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
94
98
  */
95
- declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultStoreProvider;
99
+ declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
96
100
 
97
101
  export { type OidcVaultRedisClient, OidcVaultRedisStoreRecordError, type RedisOidcVaultStoreOptions, createRedisOidcVaultStore };
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /**
4
4
  * Thrown when a stored Redis value cannot be parsed or fails structural
@@ -34,7 +34,7 @@ declare class OidcVaultRedisStoreRecordError extends Error {
34
34
  *
35
35
  * `sendCommand` is required because atomic session writes, rotations,
36
36
  * deletions, and one-time record consumption transit Redis
37
- * `EVAL`/`GETDEL`/`TYPE`/`TIME`/`ZRANGE`/`ZSCAN`/`MGET` commands through it.
37
+ * cached Lua/`TYPE`/`TIME`/`ZRANGE`/`ZSCAN`/`MGET` commands through it.
38
38
  * It was previously optional in this type but always enforced at runtime.
39
39
  */
40
40
  interface OidcVaultRedisClient {
@@ -59,6 +59,8 @@ interface OidcVaultRedisClient {
59
59
  * `disconnect()`). The package never calls `connect`, `quit`, or `disconnect`.
60
60
  */
61
61
  interface RedisOidcVaultStoreOptions {
62
+ /** Positive safe integer per shared keyPrefix; default 100000. Configure identically on all clients. */
63
+ dpopReplayMaxEntries?: number;
62
64
  /** Connected Redis client or compatible adapter implementing {@link OidcVaultRedisClient}. */
63
65
  client: OidcVaultRedisClient;
64
66
  /**
@@ -77,7 +79,7 @@ interface RedisOidcVaultStoreOptions {
77
79
  now?: () => number;
78
80
  }
79
81
  /**
80
- * Create an `OidcVaultStoreProvider` backed by a connected Redis client.
82
+ * Create an `OidcVaultDeviceBindingStoreProvider` backed by a connected Redis client.
81
83
  *
82
84
  * Pass an already-connected official `redis` standalone client (or any adapter
83
85
  * implementing {@link OidcVaultRedisClient}). For Redis Sentinel deployments,
@@ -86,12 +88,14 @@ interface RedisOidcVaultStoreOptions {
86
88
  * satisfy the structural contract. The store does not connect, disconnect, or
87
89
  * attach `error` listeners; it only issues commands.
88
90
  *
89
- * The package requires Redis 6.2 or later (it uses `GETDEL`). Redis Cluster is
90
- * not supported and is rejected here.
91
+ * The package supports Redis 6.2 or later. Redis Cluster is
92
+ * not supported and is rejected here. Guarded consumes and per-proof replay use
93
+ * cached atomic Lua; replay is shared per keyPrefix, uses server TIME, and never
94
+ * evicts live entries. All clients must use the same dpopReplayMaxEntries.
91
95
  *
92
96
  * @param options construction options; see {@link RedisOidcVaultStoreOptions}.
93
- * @returns a provider satisfying the core `OidcVaultStoreProvider` contract.
97
+ * @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
94
98
  */
95
- declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultStoreProvider;
99
+ declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
96
100
 
97
101
  export { type OidcVaultRedisClient, OidcVaultRedisStoreRecordError, type RedisOidcVaultStoreOptions, createRedisOidcVaultStore };
package/index.js CHANGED
@@ -67,6 +67,9 @@ var RedisOidcVaultStoreKeys = class {
67
67
  backchannelLogoutTokenJti(jti) {
68
68
  return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
69
69
  }
70
+ dpopProofs() {
71
+ return `${this.keyPrefix}:dpop-proofs`;
72
+ }
70
73
  rotatedSessionAliasIndex(logicalSessionId) {
71
74
  return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
72
75
  }
@@ -94,14 +97,27 @@ var OidcVaultRedisStoreRecordError = class extends Error {
94
97
  };
95
98
  var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
96
99
  var isString = (value) => typeof value === "string";
100
+ var isBindingHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
101
+ var validateDpopBinding = (value) => isPlainRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isBindingHash(value.jkt);
102
+ var validateRecordBinding = (record) => (record.deviceBinding === void 0 || validateDpopBinding(record.deviceBinding)) && (record.browserBindingHash === void 0 || isBindingHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
103
+ var validateBindingMatch = (value) => isPlainRecord(value) && Object.hasOwn(value, "deviceBinding") && Object.hasOwn(value, "browserBindingHash") && (value.deviceBinding === null || validateDpopBinding(value.deviceBinding)) && (value.browserBindingHash === null || isBindingHash(value.browserBindingHash));
104
+ var assertRecordBinding = (record) => {
105
+ if (!validateRecordBinding(record))
106
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
107
+ };
108
+ var assertSessionBinding = (record) => {
109
+ if (record.deviceBinding !== void 0 && !validateDpopBinding(record.deviceBinding)) {
110
+ throw new TypeError("OIDC vault store session has invalid device binding.");
111
+ }
112
+ };
97
113
  var isOptionalString = (value) => value === void 0 || isString(value);
98
114
  var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
99
115
  var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
100
116
  var isStringRecord = (value) => isPlainRecord(value);
101
- var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
102
- var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
117
+ var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
118
+ var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
103
119
  var validateProviderMetadata = (value) => value === void 0 || isPlainRecord(value) && isOptionalString(value.issuer) && isOptionalString(value.clientId);
104
- var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
120
+ var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && (value.deviceBinding === void 0 || validateDpopBinding(value.deviceBinding)) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
105
121
  var parseStoredJson = (value, recordKind, validate) => {
106
122
  if (value === null) {
107
123
  return null;
@@ -130,6 +146,228 @@ var evalCommand = (script, keys, args) => [
130
146
  ...keys,
131
147
  ...args
132
148
  ];
149
+ var BINDING_VALIDATION_HELPER = `
150
+ local function isHash(value)
151
+ return type(value) == 'string' and #value == 43
152
+ and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
153
+ and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
154
+ end
155
+ local function isBinding(value)
156
+ if type(value) ~= 'table' or value['type'] ~= 'dpop' or not isHash(value['jkt']) then return false end
157
+ local count = 0
158
+ for key, _ in pairs(value) do
159
+ if key ~= 'type' and key ~= 'jkt' then return false end
160
+ count = count + 1
161
+ end
162
+ return count == 2
163
+ end
164
+ local function validBinding(value)
165
+ return value == nil or isBinding(value)
166
+ end
167
+ local function validRecordBinding(record)
168
+ return validBinding(record['deviceBinding'])
169
+ and (record['browserBindingHash'] == nil or isHash(record['browserBindingHash']))
170
+ and (record['deviceBinding'] == nil or record['browserBindingHash'] ~= nil)
171
+ end
172
+ local function sameBinding(left, right)
173
+ if left == nil or right == nil then return left == right end
174
+ return isBinding(left) and isBinding(right) and left['jkt'] == right['jkt']
175
+ end
176
+ local function optionalString(value)
177
+ return value == nil or type(value) == 'string'
178
+ end
179
+ local function finiteNumber(value)
180
+ return type(value) == 'number' and value == value and value > -math.huge and value < math.huge
181
+ end
182
+ local function serverNow()
183
+ local time = redis.call('TIME')
184
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
185
+ end
186
+ -- cjson decodes both {} and [] to a Lua table. Inspect the top-level value's
187
+ -- lexical kind so an empty provider array cannot masquerade as legacy {}.
188
+ -- Skip strings/escapes and nested containers; duplicate keys use the last
189
+ -- occurrence, matching cjson/JSON.parse. No credential values are returned.
190
+ local function optionalObjectField(raw, wanted)
191
+ local depth, position, shape = 0, 1, nil
192
+ while position <= #raw do
193
+ local char = string.sub(raw, position, position)
194
+ if char == '"' then
195
+ local start = position
196
+ position = position + 1
197
+ while position <= #raw do
198
+ local current = string.sub(raw, position, position)
199
+ if current == '\\\\' then position = position + 2
200
+ elseif current == '"' then break
201
+ else position = position + 1 end
202
+ end
203
+ if depth == 1 then
204
+ local after = position + 1
205
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
206
+ if string.sub(raw, after, after) == ':' and cjson.decode(string.sub(raw, start, position)) == wanted then
207
+ after = after + 1
208
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
209
+ shape = string.sub(raw, after, after)
210
+ end
211
+ end
212
+ elseif char == '{' or char == '[' then depth = depth + 1
213
+ elseif char == '}' or char == ']' then depth = depth - 1 end
214
+ position = position + 1
215
+ end
216
+ return shape == nil or shape == '{'
217
+ end
218
+ `;
219
+ var CONSUME_GUARDED_RECORD_SCRIPT = `
220
+ ${BINDING_VALIDATION_HELPER}
221
+ local raw = redis.call('GET', KEYS[1])
222
+ if raw == false then return false end
223
+ local ok, record = pcall(cjson.decode, raw)
224
+ local kind = ARGV[1]
225
+ local valid = ok and type(record) == 'table' and validRecordBinding(record)
226
+ if valid then
227
+ valid = optionalString(record['returnTo']) and finiteNumber(record['createdAt']) and finiteNumber(record['expiresAt'])
228
+ if kind == 'transaction' then
229
+ valid = valid and record['state'] == ARGV[2] and type(record['nonce']) == 'string'
230
+ and type(record['pkceVerifier']) == 'string' and type(record['codeChallenge']) == 'string'
231
+ and (record['metadata'] == nil or type(record['metadata']) == 'table')
232
+ else
233
+ valid = valid and record['code'] == ARGV[2] and type(record['sessionId']) == 'string'
234
+ end
235
+ end
236
+ if not valid then
237
+ redis.call('DEL', KEYS[1])
238
+ return false
239
+ end
240
+ -- Legacy consumes preserve the historical Redis key-TTL policy. Guarded
241
+ -- consumes additionally check payload expiry with server time (D5).
242
+ if ARGV[3] ~= 'legacy' and record['expiresAt'] <= serverNow() then
243
+ redis.call('DEL', KEYS[1])
244
+ return false
245
+ end
246
+ if ARGV[3] == 'legacy' then
247
+ if record['deviceBinding'] ~= nil or record['browserBindingHash'] ~= nil then return false end
248
+ else
249
+ local match = cjson.decode(ARGV[4])
250
+ if (match['deviceBinding'] ~= cjson.null and not isBinding(match['deviceBinding']))
251
+ or (match['browserBindingHash'] ~= cjson.null and not isHash(match['browserBindingHash'])) then return false end
252
+ if match['deviceBinding'] == cjson.null then
253
+ if record['deviceBinding'] ~= nil then return false end
254
+ elseif not sameBinding(record['deviceBinding'], match['deviceBinding']) then return false end
255
+ if match['browserBindingHash'] == cjson.null then
256
+ if record['browserBindingHash'] ~= nil then return false end
257
+ elseif record['browserBindingHash'] ~= match['browserBindingHash'] then return false end
258
+ if kind == 'exchange' and record['sessionId'] ~= ARGV[5] then return false end
259
+ end
260
+ redis.call('DEL', KEYS[1])
261
+ return raw
262
+ `;
263
+ var buildConsumeGuardedRecordCommand = (key, kind, id, match, expectedSessionId) => evalCommand(
264
+ CONSUME_GUARDED_RECORD_SCRIPT,
265
+ [key],
266
+ [
267
+ kind,
268
+ id,
269
+ match === void 0 ? "legacy" : "guarded",
270
+ match === void 0 ? "" : serialize(match),
271
+ expectedSessionId ?? ""
272
+ ]
273
+ );
274
+ var RESERVE_DPOP_PROOF_SCRIPT = `
275
+ ${BINDING_VALIDATION_HELPER}
276
+ local expiresAt = tonumber(ARGV[2])
277
+ local now = serverNow()
278
+ if not finiteNumber(expiresAt) or expiresAt ~= math.floor(expiresAt) or expiresAt > 9007199254740991
279
+ or expiresAt <= now or expiresAt - now > 360000 then return 0 end
280
+ local keyType = redis.call('TYPE', KEYS[1])['ok']
281
+ if keyType ~= 'none' and keyType ~= 'zset' then error('OIDC vault Redis replay key has unexpected type') end
282
+ local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
283
+ if existing ~= false and tonumber(existing) > now then return 0 end
284
+ -- Hard LIMIT, not SCAN's hint: at most 64 expired entries per admission.
285
+ local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 64)
286
+ if #expired > 0 then redis.call('ZREM', KEYS[1], unpack(expired)) end
287
+ if existing ~= false then redis.call('ZREM', KEYS[1], ARGV[1]) end
288
+ if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 2 end
289
+ redis.call('ZADD', KEYS[1], expiresAt, ARGV[1])
290
+ -- Reclaim the whole set if traffic stops after its last retained expiry.
291
+ local last = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
292
+ redis.call('PEXPIREAT', KEYS[1], last[2])
293
+ return 1
294
+ `;
295
+ var buildReserveDpopProofCommand = (keys, input, maxEntries) => evalCommand(
296
+ RESERVE_DPOP_PROOF_SCRIPT,
297
+ [keys.dpopProofs()],
298
+ [input.replayKey, String(input.expiresAt), String(maxEntries)]
299
+ );
300
+ var SESSION_REVOCATION_CONTEXT_SCRIPT = `
301
+ ${BINDING_VALIDATION_HELPER}
302
+ local now = serverNow()
303
+ local handle = ARGV[1]
304
+ local sessionPrefix = ARGV[2]
305
+ local logicalPrefix = ARGV[3]
306
+ local function authority(raw, id)
307
+ local ok, record = pcall(cjson.decode, raw)
308
+ if not ok or type(record) ~= 'table' or record['sessionId'] ~= id
309
+ or not optionalString(record['logicalSessionId']) or type(record['subject']) ~= 'string'
310
+ or not validBinding(record['deviceBinding']) then error('OIDC vault Redis lineage has malformed session authority') end
311
+ local provider = record['provider']
312
+ if not optionalObjectField(raw, 'provider') or (provider ~= nil and (type(provider) ~= 'table' or not optionalString(provider['issuer'])
313
+ or not optionalString(provider['clientId']))) then error('OIDC vault Redis lineage has malformed provider identity') end
314
+ local expiry = record['expiresAt']
315
+ if expiry ~= nil and not finiteNumber(expiry) then error('OIDC vault Redis lineage has malformed session expiry') end
316
+ if expiry ~= nil and expiry <= now then return nil end
317
+ return record
318
+ end
319
+ local logicalId = nil
320
+ local source = nil
321
+ local primary = redis.call('GET', KEYS[1])
322
+ if primary ~= false then
323
+ local record = authority(primary, handle)
324
+ if record ~= nil then
325
+ logicalId = record['logicalSessionId'] or handle
326
+ -- The directly observed live handle must participate even if its reverse
327
+ -- index membership was corrupted; never infer unboundness from peers alone.
328
+ source = record
329
+ end
330
+ end
331
+ if logicalId == nil then
332
+ local alias = redis.call('GET', KEYS[2])
333
+ if alias == false then return false end
334
+ local ok, decoded = pcall(cjson.decode, alias)
335
+ if not ok or type(decoded) ~= 'string' then error('OIDC vault Redis store has malformed revocation alias') end
336
+ logicalId = decoded
337
+ end
338
+ local members = redis.call('ZRANGE', logicalPrefix .. logicalId, 0, -1)
339
+ for _, id in ipairs(members) do
340
+ local raw = redis.call('GET', sessionPrefix .. id)
341
+ if raw ~= false then
342
+ local member = authority(raw, id)
343
+ if member ~= nil and (member['logicalSessionId'] or id) == logicalId then
344
+ if source ~= nil then
345
+ local left = source['provider'] or {}
346
+ local right = member['provider'] or {}
347
+ if not sameBinding(source['deviceBinding'], member['deviceBinding']) or source['subject'] ~= member['subject']
348
+ or left['issuer'] ~= right['issuer'] or left['clientId'] ~= right['clientId'] then
349
+ error('OIDC vault Redis lineage has inconsistent device binding or provider identity')
350
+ end
351
+ else source = member end
352
+ end
353
+ end
354
+ end
355
+ if source == nil then return false end
356
+ local result = { logicalSessionId = logicalId }
357
+ if source['deviceBinding'] ~= nil then result['deviceBinding'] = source['deviceBinding'] end
358
+ if source['provider'] ~= nil then
359
+ local provider = {}
360
+ provider['issuer'] = source['provider']['issuer']
361
+ provider['clientId'] = source['provider']['clientId']
362
+ result['provider'] = provider
363
+ end
364
+ return cjson.encode(result)
365
+ `;
366
+ var buildSessionRevocationContextCommand = (keys, sessionId) => evalCommand(
367
+ SESSION_REVOCATION_CONTEXT_SCRIPT,
368
+ [keys.session(sessionId), keys.rotatedSessionAlias(sessionId)],
369
+ [sessionId, keys.sessionPrefix(), keys.logicalSessionIndexPrefix()]
370
+ );
133
371
  var scriptSha1 = (script) => (0, import_node_crypto.createHash)("sha1").update(script).digest("hex");
134
372
  var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
135
373
  var isNoScriptError = (error) => {
@@ -449,6 +687,7 @@ end
449
687
  return deleted
450
688
  `;
451
689
  var ROTATE_SESSION_SCRIPT = `
690
+ ${BINDING_VALIDATION_HELPER}
452
691
  local newValue = ARGV[1]
453
692
  local newExpiresAt = ARGV[2]
454
693
  local oldSessionId = ARGV[3]
@@ -462,6 +701,7 @@ local oldAliasValue = ARGV[10]
462
701
  local oldAliasExpiresAt = ARGV[11]
463
702
  local aliasIndexKey = ARGV[12]
464
703
  local aliasIndexKeyPrefix = ARGV[13]
704
+ local expectedSource = cjson.decode(ARGV[14])
465
705
 
466
706
  local function assertStringOrMissing(key)
467
707
  local keyType = redis.call('TYPE', key)['ok']
@@ -520,6 +760,25 @@ if redis.call('EXISTS', KEYS[1]) == 0 then
520
760
  return 0
521
761
  end
522
762
 
763
+ local ok, source = pcall(cjson.decode, redis.call('GET', KEYS[1]))
764
+ local next = cjson.decode(newValue)
765
+ if not ok or type(source) ~= 'table' or source['sessionId'] ~= oldSessionId
766
+ or not validBinding(source['deviceBinding']) or not validBinding(next['deviceBinding']) then return 3 end
767
+ local sourceProvider = source['provider'] or {}
768
+ local expectedProvider = expectedSource['provider'] or {}
769
+ if type(sourceProvider) ~= 'table' or type(expectedProvider) ~= 'table' then return 3 end
770
+ -- Source authority CAS protects immutable binding and index ownership from
771
+ -- same-ID replacement between the application preflight and this script.
772
+ if (source['logicalSessionId'] or oldSessionId) ~= (expectedSource['logicalSessionId'] or oldSessionId)
773
+ or source['subject'] ~= expectedSource['subject'] or source['providerSessionId'] ~= expectedSource['providerSessionId']
774
+ or sourceProvider['issuer'] ~= expectedProvider['issuer'] or sourceProvider['clientId'] ~= expectedProvider['clientId']
775
+ or source['refreshToken'] ~= expectedSource['refreshToken'] or source['idToken'] ~= expectedSource['idToken']
776
+ or source['accessToken'] ~= expectedSource['accessToken'] or source['scope'] ~= expectedSource['scope']
777
+ or source['createdAt'] ~= expectedSource['createdAt'] or source['updatedAt'] ~= expectedSource['updatedAt']
778
+ or source['expiresAt'] ~= expectedSource['expiresAt']
779
+ or not sameBinding(source['deviceBinding'], expectedSource['deviceBinding'])
780
+ or not sameBinding(source['deviceBinding'], next['deviceBinding']) then return 3 end
781
+
523
782
  if oldSessionId == newSessionId then
524
783
  return 2
525
784
  end
@@ -653,7 +912,8 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
653
912
  serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
654
913
  serializeExpiresAt(nextSession.expiresAt),
655
914
  keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
656
- keys.rotatedSessionAliasIndexPrefix()
915
+ keys.rotatedSessionAliasIndexPrefix(),
916
+ serialize(previousSession)
657
917
  ]
658
918
  );
659
919
 
@@ -699,7 +959,12 @@ var RedisOidcVaultStore = class {
699
959
  now;
700
960
  scriptRunner;
701
961
  indexCleanupCursor = "0";
962
+ dpopReplayMaxEntries;
702
963
  constructor(options) {
964
+ this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
965
+ if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
966
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
967
+ }
703
968
  if (typeof options.client.sendCommand !== "function") {
704
969
  throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
705
970
  }
@@ -714,24 +979,91 @@ var RedisOidcVaultStore = class {
714
979
  this.scriptRunner = new RedisScriptRunner(options.client);
715
980
  }
716
981
  async createAuthorizationTransaction(input) {
982
+ input = snapshotInput({ ...input });
983
+ assertRecordBinding(input);
717
984
  await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
718
985
  }
719
986
  async consumeAuthorizationTransaction(state) {
720
- return this.consumeJson(
987
+ const value = await this.runScript(
988
+ buildConsumeGuardedRecordCommand(this.keys.authorizationTransaction(state), "transaction", state)
989
+ );
990
+ return this.parseOneTimeRecord(
991
+ value,
992
+ "authorization transaction",
993
+ (value2) => validateAuthorizationTransaction(value2, state)
994
+ );
995
+ }
996
+ async getAuthorizationTransaction(state) {
997
+ return this.getJson(
721
998
  this.keys.authorizationTransaction(state),
722
999
  "authorization transaction",
723
- (value) => validateAuthorizationTransaction(value, state)
1000
+ (value) => validateAuthorizationTransaction(value, state),
1001
+ { deleteMalformed: true, checkExpiry: true }
1002
+ );
1003
+ }
1004
+ async consumeAuthorizationTransactionIfMatches(input) {
1005
+ input = snapshotInput({ ...input });
1006
+ if (!validateBindingMatch(input.match)) return null;
1007
+ const value = await this.runScript(
1008
+ buildConsumeGuardedRecordCommand(
1009
+ this.keys.authorizationTransaction(input.state),
1010
+ "transaction",
1011
+ input.state,
1012
+ input.match
1013
+ )
1014
+ );
1015
+ return this.parseOneTimeRecord(
1016
+ value,
1017
+ "authorization transaction",
1018
+ (value2) => validateAuthorizationTransaction(value2, input.state)
724
1019
  );
725
1020
  }
726
1021
  async createExchangeCode(input) {
1022
+ input = snapshotInput({ ...input });
1023
+ assertRecordBinding(input);
727
1024
  await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
728
1025
  }
729
1026
  async consumeExchangeCode(code) {
730
- return this.consumeJson(
1027
+ const value = await this.runScript(
1028
+ buildConsumeGuardedRecordCommand(this.keys.exchangeCode(code), "exchange", code)
1029
+ );
1030
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, code));
1031
+ }
1032
+ async getExchangeCode(code) {
1033
+ return this.getJson(
731
1034
  this.keys.exchangeCode(code),
732
1035
  "exchange code",
733
- (value) => validateExchangeCodeRecord(value, code)
1036
+ (value) => validateExchangeCodeRecord(value, code),
1037
+ { deleteMalformed: true, checkExpiry: true }
1038
+ );
1039
+ }
1040
+ async consumeExchangeCodeIfMatches(input) {
1041
+ input = snapshotInput({ ...input });
1042
+ if (!validateBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
1043
+ const value = await this.runScript(
1044
+ buildConsumeGuardedRecordCommand(
1045
+ this.keys.exchangeCode(input.code),
1046
+ "exchange",
1047
+ input.code,
1048
+ input.match,
1049
+ input.expectedSessionId
1050
+ )
734
1051
  );
1052
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, input.code));
1053
+ }
1054
+ async getSessionRevocationContext(sessionId) {
1055
+ const raw = await this.runScript(buildSessionRevocationContextCommand(this.keys, sessionId));
1056
+ if (typeof raw !== "string") return null;
1057
+ const context = JSON.parse(raw);
1058
+ if (Array.isArray(context.provider) && context.provider.length === 0) context.provider = {};
1059
+ return context;
1060
+ }
1061
+ async reserveDpopProof(input) {
1062
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
1063
+ if (typeof input.replayKey !== "string" || !Number.isSafeInteger(input.expiresAt)) return false;
1064
+ const result = await this.runScript(buildReserveDpopProofCommand(this.keys, input, this.dpopReplayMaxEntries));
1065
+ if (result === 2 || result === "2") throw new import_express_oidc_vault.OidcVaultDpopReplayCapacityError();
1066
+ return result === 1 || result === "1";
735
1067
  }
736
1068
  /**
737
1069
  * Creates a session record atomically, then runs best-effort index
@@ -749,6 +1081,7 @@ var RedisOidcVaultStore = class {
749
1081
  */
750
1082
  async createSession(input) {
751
1083
  input = snapshotInput({ ...input });
1084
+ assertSessionBinding(input);
752
1085
  const timestamp = this.now();
753
1086
  const session = {
754
1087
  ...input,
@@ -770,6 +1103,7 @@ var RedisOidcVaultStore = class {
770
1103
  }
771
1104
  async rotateSession(input) {
772
1105
  input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
1106
+ assertSessionBinding(input.nextSession);
773
1107
  const previousSession = await this.getSession(input.sessionId);
774
1108
  if (!previousSession) {
775
1109
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
@@ -777,8 +1111,12 @@ var RedisOidcVaultStore = class {
777
1111
  if (input.nextSession.sessionId === input.sessionId) {
778
1112
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
779
1113
  }
1114
+ if (input.nextSession.deviceBinding !== void 0 && input.nextSession.deviceBinding.jkt !== previousSession.deviceBinding?.jkt) {
1115
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
1116
+ }
780
1117
  const nextSession = {
781
1118
  ...input.nextSession,
1119
+ ...previousSession.deviceBinding === void 0 ? {} : { deviceBinding: { ...previousSession.deviceBinding } },
782
1120
  logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
783
1121
  };
784
1122
  const rotated = await this.rotateSessionRecord(previousSession, nextSession);
@@ -835,7 +1173,10 @@ var RedisOidcVaultStore = class {
835
1173
  async getJson(key, recordKind, validate, options) {
836
1174
  const raw = await this.client.get(key);
837
1175
  try {
838
- return parseStoredJson(raw, recordKind, validate);
1176
+ const record = parseStoredJson(raw, recordKind, validate);
1177
+ if (record !== null && options?.checkExpiry && isPlainRecord(record) && typeof record.expiresAt === "number" && record.expiresAt <= await this.redisServerTime())
1178
+ return null;
1179
+ return record;
839
1180
  } catch (error) {
840
1181
  if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
841
1182
  await this.deleteMalformedValueIfUnchanged(key, raw);
@@ -844,8 +1185,7 @@ var RedisOidcVaultStore = class {
844
1185
  throw error;
845
1186
  }
846
1187
  }
847
- async consumeJson(key, recordKind, validate) {
848
- const value = await this.sendCommand(["GETDEL", key]);
1188
+ parseOneTimeRecord(value, recordKind, validate) {
849
1189
  try {
850
1190
  return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
851
1191
  } catch (error) {
@@ -905,6 +1245,9 @@ var RedisOidcVaultStore = class {
905
1245
  if (result === 2 || result === "2") {
906
1246
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
907
1247
  }
1248
+ if (result === 3 || result === "3") {
1249
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
1250
+ }
908
1251
  return result === 1 || result === "1";
909
1252
  }
910
1253
  async runScript(command) {
package/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/index.ts
2
2
  import {
3
- OidcVaultStoreConflictError
3
+ OidcVaultStoreConflictError,
4
+ OidcVaultDpopReplayCapacityError
4
5
  } from "@web-ts-toolkit/express-oidc-vault";
5
6
 
6
7
  // src/keys.ts
@@ -44,6 +45,9 @@ var RedisOidcVaultStoreKeys = class {
44
45
  backchannelLogoutTokenJti(jti) {
45
46
  return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
46
47
  }
48
+ dpopProofs() {
49
+ return `${this.keyPrefix}:dpop-proofs`;
50
+ }
47
51
  rotatedSessionAliasIndex(logicalSessionId) {
48
52
  return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
49
53
  }
@@ -71,14 +75,27 @@ var OidcVaultRedisStoreRecordError = class extends Error {
71
75
  };
72
76
  var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
73
77
  var isString = (value) => typeof value === "string";
78
+ var isBindingHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
79
+ var validateDpopBinding = (value) => isPlainRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isBindingHash(value.jkt);
80
+ var validateRecordBinding = (record) => (record.deviceBinding === void 0 || validateDpopBinding(record.deviceBinding)) && (record.browserBindingHash === void 0 || isBindingHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
81
+ var validateBindingMatch = (value) => isPlainRecord(value) && Object.hasOwn(value, "deviceBinding") && Object.hasOwn(value, "browserBindingHash") && (value.deviceBinding === null || validateDpopBinding(value.deviceBinding)) && (value.browserBindingHash === null || isBindingHash(value.browserBindingHash));
82
+ var assertRecordBinding = (record) => {
83
+ if (!validateRecordBinding(record))
84
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
85
+ };
86
+ var assertSessionBinding = (record) => {
87
+ if (record.deviceBinding !== void 0 && !validateDpopBinding(record.deviceBinding)) {
88
+ throw new TypeError("OIDC vault store session has invalid device binding.");
89
+ }
90
+ };
74
91
  var isOptionalString = (value) => value === void 0 || isString(value);
75
92
  var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
76
93
  var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
77
94
  var isStringRecord = (value) => isPlainRecord(value);
78
- var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
79
- var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
95
+ var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
96
+ var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
80
97
  var validateProviderMetadata = (value) => value === void 0 || isPlainRecord(value) && isOptionalString(value.issuer) && isOptionalString(value.clientId);
81
- var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
98
+ var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && (value.deviceBinding === void 0 || validateDpopBinding(value.deviceBinding)) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
82
99
  var parseStoredJson = (value, recordKind, validate) => {
83
100
  if (value === null) {
84
101
  return null;
@@ -107,6 +124,228 @@ var evalCommand = (script, keys, args) => [
107
124
  ...keys,
108
125
  ...args
109
126
  ];
127
+ var BINDING_VALIDATION_HELPER = `
128
+ local function isHash(value)
129
+ return type(value) == 'string' and #value == 43
130
+ and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
131
+ and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
132
+ end
133
+ local function isBinding(value)
134
+ if type(value) ~= 'table' or value['type'] ~= 'dpop' or not isHash(value['jkt']) then return false end
135
+ local count = 0
136
+ for key, _ in pairs(value) do
137
+ if key ~= 'type' and key ~= 'jkt' then return false end
138
+ count = count + 1
139
+ end
140
+ return count == 2
141
+ end
142
+ local function validBinding(value)
143
+ return value == nil or isBinding(value)
144
+ end
145
+ local function validRecordBinding(record)
146
+ return validBinding(record['deviceBinding'])
147
+ and (record['browserBindingHash'] == nil or isHash(record['browserBindingHash']))
148
+ and (record['deviceBinding'] == nil or record['browserBindingHash'] ~= nil)
149
+ end
150
+ local function sameBinding(left, right)
151
+ if left == nil or right == nil then return left == right end
152
+ return isBinding(left) and isBinding(right) and left['jkt'] == right['jkt']
153
+ end
154
+ local function optionalString(value)
155
+ return value == nil or type(value) == 'string'
156
+ end
157
+ local function finiteNumber(value)
158
+ return type(value) == 'number' and value == value and value > -math.huge and value < math.huge
159
+ end
160
+ local function serverNow()
161
+ local time = redis.call('TIME')
162
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
163
+ end
164
+ -- cjson decodes both {} and [] to a Lua table. Inspect the top-level value's
165
+ -- lexical kind so an empty provider array cannot masquerade as legacy {}.
166
+ -- Skip strings/escapes and nested containers; duplicate keys use the last
167
+ -- occurrence, matching cjson/JSON.parse. No credential values are returned.
168
+ local function optionalObjectField(raw, wanted)
169
+ local depth, position, shape = 0, 1, nil
170
+ while position <= #raw do
171
+ local char = string.sub(raw, position, position)
172
+ if char == '"' then
173
+ local start = position
174
+ position = position + 1
175
+ while position <= #raw do
176
+ local current = string.sub(raw, position, position)
177
+ if current == '\\\\' then position = position + 2
178
+ elseif current == '"' then break
179
+ else position = position + 1 end
180
+ end
181
+ if depth == 1 then
182
+ local after = position + 1
183
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
184
+ if string.sub(raw, after, after) == ':' and cjson.decode(string.sub(raw, start, position)) == wanted then
185
+ after = after + 1
186
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
187
+ shape = string.sub(raw, after, after)
188
+ end
189
+ end
190
+ elseif char == '{' or char == '[' then depth = depth + 1
191
+ elseif char == '}' or char == ']' then depth = depth - 1 end
192
+ position = position + 1
193
+ end
194
+ return shape == nil or shape == '{'
195
+ end
196
+ `;
197
+ var CONSUME_GUARDED_RECORD_SCRIPT = `
198
+ ${BINDING_VALIDATION_HELPER}
199
+ local raw = redis.call('GET', KEYS[1])
200
+ if raw == false then return false end
201
+ local ok, record = pcall(cjson.decode, raw)
202
+ local kind = ARGV[1]
203
+ local valid = ok and type(record) == 'table' and validRecordBinding(record)
204
+ if valid then
205
+ valid = optionalString(record['returnTo']) and finiteNumber(record['createdAt']) and finiteNumber(record['expiresAt'])
206
+ if kind == 'transaction' then
207
+ valid = valid and record['state'] == ARGV[2] and type(record['nonce']) == 'string'
208
+ and type(record['pkceVerifier']) == 'string' and type(record['codeChallenge']) == 'string'
209
+ and (record['metadata'] == nil or type(record['metadata']) == 'table')
210
+ else
211
+ valid = valid and record['code'] == ARGV[2] and type(record['sessionId']) == 'string'
212
+ end
213
+ end
214
+ if not valid then
215
+ redis.call('DEL', KEYS[1])
216
+ return false
217
+ end
218
+ -- Legacy consumes preserve the historical Redis key-TTL policy. Guarded
219
+ -- consumes additionally check payload expiry with server time (D5).
220
+ if ARGV[3] ~= 'legacy' and record['expiresAt'] <= serverNow() then
221
+ redis.call('DEL', KEYS[1])
222
+ return false
223
+ end
224
+ if ARGV[3] == 'legacy' then
225
+ if record['deviceBinding'] ~= nil or record['browserBindingHash'] ~= nil then return false end
226
+ else
227
+ local match = cjson.decode(ARGV[4])
228
+ if (match['deviceBinding'] ~= cjson.null and not isBinding(match['deviceBinding']))
229
+ or (match['browserBindingHash'] ~= cjson.null and not isHash(match['browserBindingHash'])) then return false end
230
+ if match['deviceBinding'] == cjson.null then
231
+ if record['deviceBinding'] ~= nil then return false end
232
+ elseif not sameBinding(record['deviceBinding'], match['deviceBinding']) then return false end
233
+ if match['browserBindingHash'] == cjson.null then
234
+ if record['browserBindingHash'] ~= nil then return false end
235
+ elseif record['browserBindingHash'] ~= match['browserBindingHash'] then return false end
236
+ if kind == 'exchange' and record['sessionId'] ~= ARGV[5] then return false end
237
+ end
238
+ redis.call('DEL', KEYS[1])
239
+ return raw
240
+ `;
241
+ var buildConsumeGuardedRecordCommand = (key, kind, id, match, expectedSessionId) => evalCommand(
242
+ CONSUME_GUARDED_RECORD_SCRIPT,
243
+ [key],
244
+ [
245
+ kind,
246
+ id,
247
+ match === void 0 ? "legacy" : "guarded",
248
+ match === void 0 ? "" : serialize(match),
249
+ expectedSessionId ?? ""
250
+ ]
251
+ );
252
+ var RESERVE_DPOP_PROOF_SCRIPT = `
253
+ ${BINDING_VALIDATION_HELPER}
254
+ local expiresAt = tonumber(ARGV[2])
255
+ local now = serverNow()
256
+ if not finiteNumber(expiresAt) or expiresAt ~= math.floor(expiresAt) or expiresAt > 9007199254740991
257
+ or expiresAt <= now or expiresAt - now > 360000 then return 0 end
258
+ local keyType = redis.call('TYPE', KEYS[1])['ok']
259
+ if keyType ~= 'none' and keyType ~= 'zset' then error('OIDC vault Redis replay key has unexpected type') end
260
+ local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
261
+ if existing ~= false and tonumber(existing) > now then return 0 end
262
+ -- Hard LIMIT, not SCAN's hint: at most 64 expired entries per admission.
263
+ local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 64)
264
+ if #expired > 0 then redis.call('ZREM', KEYS[1], unpack(expired)) end
265
+ if existing ~= false then redis.call('ZREM', KEYS[1], ARGV[1]) end
266
+ if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 2 end
267
+ redis.call('ZADD', KEYS[1], expiresAt, ARGV[1])
268
+ -- Reclaim the whole set if traffic stops after its last retained expiry.
269
+ local last = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
270
+ redis.call('PEXPIREAT', KEYS[1], last[2])
271
+ return 1
272
+ `;
273
+ var buildReserveDpopProofCommand = (keys, input, maxEntries) => evalCommand(
274
+ RESERVE_DPOP_PROOF_SCRIPT,
275
+ [keys.dpopProofs()],
276
+ [input.replayKey, String(input.expiresAt), String(maxEntries)]
277
+ );
278
+ var SESSION_REVOCATION_CONTEXT_SCRIPT = `
279
+ ${BINDING_VALIDATION_HELPER}
280
+ local now = serverNow()
281
+ local handle = ARGV[1]
282
+ local sessionPrefix = ARGV[2]
283
+ local logicalPrefix = ARGV[3]
284
+ local function authority(raw, id)
285
+ local ok, record = pcall(cjson.decode, raw)
286
+ if not ok or type(record) ~= 'table' or record['sessionId'] ~= id
287
+ or not optionalString(record['logicalSessionId']) or type(record['subject']) ~= 'string'
288
+ or not validBinding(record['deviceBinding']) then error('OIDC vault Redis lineage has malformed session authority') end
289
+ local provider = record['provider']
290
+ if not optionalObjectField(raw, 'provider') or (provider ~= nil and (type(provider) ~= 'table' or not optionalString(provider['issuer'])
291
+ or not optionalString(provider['clientId']))) then error('OIDC vault Redis lineage has malformed provider identity') end
292
+ local expiry = record['expiresAt']
293
+ if expiry ~= nil and not finiteNumber(expiry) then error('OIDC vault Redis lineage has malformed session expiry') end
294
+ if expiry ~= nil and expiry <= now then return nil end
295
+ return record
296
+ end
297
+ local logicalId = nil
298
+ local source = nil
299
+ local primary = redis.call('GET', KEYS[1])
300
+ if primary ~= false then
301
+ local record = authority(primary, handle)
302
+ if record ~= nil then
303
+ logicalId = record['logicalSessionId'] or handle
304
+ -- The directly observed live handle must participate even if its reverse
305
+ -- index membership was corrupted; never infer unboundness from peers alone.
306
+ source = record
307
+ end
308
+ end
309
+ if logicalId == nil then
310
+ local alias = redis.call('GET', KEYS[2])
311
+ if alias == false then return false end
312
+ local ok, decoded = pcall(cjson.decode, alias)
313
+ if not ok or type(decoded) ~= 'string' then error('OIDC vault Redis store has malformed revocation alias') end
314
+ logicalId = decoded
315
+ end
316
+ local members = redis.call('ZRANGE', logicalPrefix .. logicalId, 0, -1)
317
+ for _, id in ipairs(members) do
318
+ local raw = redis.call('GET', sessionPrefix .. id)
319
+ if raw ~= false then
320
+ local member = authority(raw, id)
321
+ if member ~= nil and (member['logicalSessionId'] or id) == logicalId then
322
+ if source ~= nil then
323
+ local left = source['provider'] or {}
324
+ local right = member['provider'] or {}
325
+ if not sameBinding(source['deviceBinding'], member['deviceBinding']) or source['subject'] ~= member['subject']
326
+ or left['issuer'] ~= right['issuer'] or left['clientId'] ~= right['clientId'] then
327
+ error('OIDC vault Redis lineage has inconsistent device binding or provider identity')
328
+ end
329
+ else source = member end
330
+ end
331
+ end
332
+ end
333
+ if source == nil then return false end
334
+ local result = { logicalSessionId = logicalId }
335
+ if source['deviceBinding'] ~= nil then result['deviceBinding'] = source['deviceBinding'] end
336
+ if source['provider'] ~= nil then
337
+ local provider = {}
338
+ provider['issuer'] = source['provider']['issuer']
339
+ provider['clientId'] = source['provider']['clientId']
340
+ result['provider'] = provider
341
+ end
342
+ return cjson.encode(result)
343
+ `;
344
+ var buildSessionRevocationContextCommand = (keys, sessionId) => evalCommand(
345
+ SESSION_REVOCATION_CONTEXT_SCRIPT,
346
+ [keys.session(sessionId), keys.rotatedSessionAlias(sessionId)],
347
+ [sessionId, keys.sessionPrefix(), keys.logicalSessionIndexPrefix()]
348
+ );
110
349
  var scriptSha1 = (script) => createHash("sha1").update(script).digest("hex");
111
350
  var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
112
351
  var isNoScriptError = (error) => {
@@ -426,6 +665,7 @@ end
426
665
  return deleted
427
666
  `;
428
667
  var ROTATE_SESSION_SCRIPT = `
668
+ ${BINDING_VALIDATION_HELPER}
429
669
  local newValue = ARGV[1]
430
670
  local newExpiresAt = ARGV[2]
431
671
  local oldSessionId = ARGV[3]
@@ -439,6 +679,7 @@ local oldAliasValue = ARGV[10]
439
679
  local oldAliasExpiresAt = ARGV[11]
440
680
  local aliasIndexKey = ARGV[12]
441
681
  local aliasIndexKeyPrefix = ARGV[13]
682
+ local expectedSource = cjson.decode(ARGV[14])
442
683
 
443
684
  local function assertStringOrMissing(key)
444
685
  local keyType = redis.call('TYPE', key)['ok']
@@ -497,6 +738,25 @@ if redis.call('EXISTS', KEYS[1]) == 0 then
497
738
  return 0
498
739
  end
499
740
 
741
+ local ok, source = pcall(cjson.decode, redis.call('GET', KEYS[1]))
742
+ local next = cjson.decode(newValue)
743
+ if not ok or type(source) ~= 'table' or source['sessionId'] ~= oldSessionId
744
+ or not validBinding(source['deviceBinding']) or not validBinding(next['deviceBinding']) then return 3 end
745
+ local sourceProvider = source['provider'] or {}
746
+ local expectedProvider = expectedSource['provider'] or {}
747
+ if type(sourceProvider) ~= 'table' or type(expectedProvider) ~= 'table' then return 3 end
748
+ -- Source authority CAS protects immutable binding and index ownership from
749
+ -- same-ID replacement between the application preflight and this script.
750
+ if (source['logicalSessionId'] or oldSessionId) ~= (expectedSource['logicalSessionId'] or oldSessionId)
751
+ or source['subject'] ~= expectedSource['subject'] or source['providerSessionId'] ~= expectedSource['providerSessionId']
752
+ or sourceProvider['issuer'] ~= expectedProvider['issuer'] or sourceProvider['clientId'] ~= expectedProvider['clientId']
753
+ or source['refreshToken'] ~= expectedSource['refreshToken'] or source['idToken'] ~= expectedSource['idToken']
754
+ or source['accessToken'] ~= expectedSource['accessToken'] or source['scope'] ~= expectedSource['scope']
755
+ or source['createdAt'] ~= expectedSource['createdAt'] or source['updatedAt'] ~= expectedSource['updatedAt']
756
+ or source['expiresAt'] ~= expectedSource['expiresAt']
757
+ or not sameBinding(source['deviceBinding'], expectedSource['deviceBinding'])
758
+ or not sameBinding(source['deviceBinding'], next['deviceBinding']) then return 3 end
759
+
500
760
  if oldSessionId == newSessionId then
501
761
  return 2
502
762
  end
@@ -630,7 +890,8 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
630
890
  serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
631
891
  serializeExpiresAt(nextSession.expiresAt),
632
892
  keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
633
- keys.rotatedSessionAliasIndexPrefix()
893
+ keys.rotatedSessionAliasIndexPrefix(),
894
+ serialize(previousSession)
634
895
  ]
635
896
  );
636
897
 
@@ -676,7 +937,12 @@ var RedisOidcVaultStore = class {
676
937
  now;
677
938
  scriptRunner;
678
939
  indexCleanupCursor = "0";
940
+ dpopReplayMaxEntries;
679
941
  constructor(options) {
942
+ this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
943
+ if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
944
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
945
+ }
680
946
  if (typeof options.client.sendCommand !== "function") {
681
947
  throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
682
948
  }
@@ -691,24 +957,91 @@ var RedisOidcVaultStore = class {
691
957
  this.scriptRunner = new RedisScriptRunner(options.client);
692
958
  }
693
959
  async createAuthorizationTransaction(input) {
960
+ input = snapshotInput({ ...input });
961
+ assertRecordBinding(input);
694
962
  await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
695
963
  }
696
964
  async consumeAuthorizationTransaction(state) {
697
- return this.consumeJson(
965
+ const value = await this.runScript(
966
+ buildConsumeGuardedRecordCommand(this.keys.authorizationTransaction(state), "transaction", state)
967
+ );
968
+ return this.parseOneTimeRecord(
969
+ value,
970
+ "authorization transaction",
971
+ (value2) => validateAuthorizationTransaction(value2, state)
972
+ );
973
+ }
974
+ async getAuthorizationTransaction(state) {
975
+ return this.getJson(
698
976
  this.keys.authorizationTransaction(state),
699
977
  "authorization transaction",
700
- (value) => validateAuthorizationTransaction(value, state)
978
+ (value) => validateAuthorizationTransaction(value, state),
979
+ { deleteMalformed: true, checkExpiry: true }
980
+ );
981
+ }
982
+ async consumeAuthorizationTransactionIfMatches(input) {
983
+ input = snapshotInput({ ...input });
984
+ if (!validateBindingMatch(input.match)) return null;
985
+ const value = await this.runScript(
986
+ buildConsumeGuardedRecordCommand(
987
+ this.keys.authorizationTransaction(input.state),
988
+ "transaction",
989
+ input.state,
990
+ input.match
991
+ )
992
+ );
993
+ return this.parseOneTimeRecord(
994
+ value,
995
+ "authorization transaction",
996
+ (value2) => validateAuthorizationTransaction(value2, input.state)
701
997
  );
702
998
  }
703
999
  async createExchangeCode(input) {
1000
+ input = snapshotInput({ ...input });
1001
+ assertRecordBinding(input);
704
1002
  await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
705
1003
  }
706
1004
  async consumeExchangeCode(code) {
707
- return this.consumeJson(
1005
+ const value = await this.runScript(
1006
+ buildConsumeGuardedRecordCommand(this.keys.exchangeCode(code), "exchange", code)
1007
+ );
1008
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, code));
1009
+ }
1010
+ async getExchangeCode(code) {
1011
+ return this.getJson(
708
1012
  this.keys.exchangeCode(code),
709
1013
  "exchange code",
710
- (value) => validateExchangeCodeRecord(value, code)
1014
+ (value) => validateExchangeCodeRecord(value, code),
1015
+ { deleteMalformed: true, checkExpiry: true }
1016
+ );
1017
+ }
1018
+ async consumeExchangeCodeIfMatches(input) {
1019
+ input = snapshotInput({ ...input });
1020
+ if (!validateBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
1021
+ const value = await this.runScript(
1022
+ buildConsumeGuardedRecordCommand(
1023
+ this.keys.exchangeCode(input.code),
1024
+ "exchange",
1025
+ input.code,
1026
+ input.match,
1027
+ input.expectedSessionId
1028
+ )
711
1029
  );
1030
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, input.code));
1031
+ }
1032
+ async getSessionRevocationContext(sessionId) {
1033
+ const raw = await this.runScript(buildSessionRevocationContextCommand(this.keys, sessionId));
1034
+ if (typeof raw !== "string") return null;
1035
+ const context = JSON.parse(raw);
1036
+ if (Array.isArray(context.provider) && context.provider.length === 0) context.provider = {};
1037
+ return context;
1038
+ }
1039
+ async reserveDpopProof(input) {
1040
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
1041
+ if (typeof input.replayKey !== "string" || !Number.isSafeInteger(input.expiresAt)) return false;
1042
+ const result = await this.runScript(buildReserveDpopProofCommand(this.keys, input, this.dpopReplayMaxEntries));
1043
+ if (result === 2 || result === "2") throw new OidcVaultDpopReplayCapacityError();
1044
+ return result === 1 || result === "1";
712
1045
  }
713
1046
  /**
714
1047
  * Creates a session record atomically, then runs best-effort index
@@ -726,6 +1059,7 @@ var RedisOidcVaultStore = class {
726
1059
  */
727
1060
  async createSession(input) {
728
1061
  input = snapshotInput({ ...input });
1062
+ assertSessionBinding(input);
729
1063
  const timestamp = this.now();
730
1064
  const session = {
731
1065
  ...input,
@@ -747,6 +1081,7 @@ var RedisOidcVaultStore = class {
747
1081
  }
748
1082
  async rotateSession(input) {
749
1083
  input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
1084
+ assertSessionBinding(input.nextSession);
750
1085
  const previousSession = await this.getSession(input.sessionId);
751
1086
  if (!previousSession) {
752
1087
  throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
@@ -754,8 +1089,12 @@ var RedisOidcVaultStore = class {
754
1089
  if (input.nextSession.sessionId === input.sessionId) {
755
1090
  throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
756
1091
  }
1092
+ if (input.nextSession.deviceBinding !== void 0 && input.nextSession.deviceBinding.jkt !== previousSession.deviceBinding?.jkt) {
1093
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
1094
+ }
757
1095
  const nextSession = {
758
1096
  ...input.nextSession,
1097
+ ...previousSession.deviceBinding === void 0 ? {} : { deviceBinding: { ...previousSession.deviceBinding } },
759
1098
  logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
760
1099
  };
761
1100
  const rotated = await this.rotateSessionRecord(previousSession, nextSession);
@@ -812,7 +1151,10 @@ var RedisOidcVaultStore = class {
812
1151
  async getJson(key, recordKind, validate, options) {
813
1152
  const raw = await this.client.get(key);
814
1153
  try {
815
- return parseStoredJson(raw, recordKind, validate);
1154
+ const record = parseStoredJson(raw, recordKind, validate);
1155
+ if (record !== null && options?.checkExpiry && isPlainRecord(record) && typeof record.expiresAt === "number" && record.expiresAt <= await this.redisServerTime())
1156
+ return null;
1157
+ return record;
816
1158
  } catch (error) {
817
1159
  if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
818
1160
  await this.deleteMalformedValueIfUnchanged(key, raw);
@@ -821,8 +1163,7 @@ var RedisOidcVaultStore = class {
821
1163
  throw error;
822
1164
  }
823
1165
  }
824
- async consumeJson(key, recordKind, validate) {
825
- const value = await this.sendCommand(["GETDEL", key]);
1166
+ parseOneTimeRecord(value, recordKind, validate) {
826
1167
  try {
827
1168
  return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
828
1169
  } catch (error) {
@@ -882,6 +1223,9 @@ var RedisOidcVaultStore = class {
882
1223
  if (result === 2 || result === "2") {
883
1224
  throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
884
1225
  }
1226
+ if (result === 3 || result === "3") {
1227
+ throw new OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
1228
+ }
885
1229
  return result === 1 || result === "1";
886
1230
  }
887
1231
  async runScript(command) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-redis-store",
3
3
  "description": "Redis store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-redis-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
  "**/*",