@web-ts-toolkit/express-oidc-vault-redis-store 0.47.1 → 0.48.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 +45 -6
- package/index.d.mts +11 -7
- package/index.d.ts +11 -7
- package/index.js +354 -11
- package/index.mjs +356 -12
- package/package.json +3 -3
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
|
-
- **
|
|
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**:
|
|
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`.
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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 `
|
|
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
|
|
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
|
|
97
|
+
* @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
|
|
94
98
|
*/
|
|
95
|
-
declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions):
|
|
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 {
|
|
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
|
-
*
|
|
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 `
|
|
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
|
|
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
|
|
97
|
+
* @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
|
|
94
98
|
*/
|
|
95
|
-
declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
+
"version": "0.48.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.
|
|
32
|
+
"@web-ts-toolkit/express-oidc-vault": "0.48.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"**/*",
|