@web-ts-toolkit/express-oidc-vault-redis-store 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -172,6 +172,20 @@ Rotation inherits omitted binding, accepts the same key, and rejects null/malfor
172
172
 
173
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
174
 
175
+ ## Dedicated Logout Continuation Tickets
176
+
177
+ The inferred factory result also satisfies core `OidcVaultLogoutNavigationStoreProvider`, requiring `createLogoutNavigationTicket` / `consumeLogoutNavigationTicket` alongside the separate device-binding capability. Import ticket/create/consume types and `OidcVaultLogoutNavigationCapacityError` from the **core root**. Both methods remain optional on the base custom-store contract; this storage extension does not register HTTP routes.
178
+
179
+ `logoutNavigationMaxEntries` is a positive safe integer, default **10000**, shared across effective ticket namespaces in one `keyPrefix`, independent of replay. First successful create persists the limit in **`<keyPrefix>:logout-navigation-capacity`** (constant-size, no expiry). Ticket operations reject a different configured limit. Unknown consumes create no configuration or per-request keys. Payloads are **`<keyPrefix>:logout-navigation-ticket:<namespace>:<ticketHash>`**, written with absolute **PXAT**. The bounded expiry/capacity ZSET is **`<keyPrefix>:logout-navigation-expiries`**; its members contain only namespace/hash identities, never URLs.
180
+
181
+ Native Lua uses server **TIME**, validates touched key types/configuration/accounting before writes, checks live collision before ZCARD capacity, then atomically updates payload/index membership. Live collision throws `OidcVaultStoreConflictError` without replacement/extension. Capacity throws `OidcVaultLogoutNavigationCapacityError`, never evicting live grants. Each operation retires its selected entry plus at most **64 unrelated expiries**. A live index entry protects an early-disappeared payload identity from reuse; consume retires it and returns null, freeing capacity once. Payload TTL does not lose index accounting. A ZSET with a live deadline expires at its latest retained deadline. If consuming the last live grant leaves only overdue accounting, the script retains that bounded ledger for later cleanup instead of expiring more than 64 entries synchronously; it can remain idle. Payloads still expire independently, and no live window is extended.
182
+
183
+ Exactly five scalar fields are captured before awaits and returned detached: `namespace`, `ticketHash`, `upstreamLogoutUrl`, `createdAt`, `expiresAt`. Core's README defines canonical scope/hash formats, 256-bit ticket generation, Date-range integer epochs, original TTL 1000..120000 ms, remaining TTL >0 and <=120000 ms, and 16384-byte HTTP(S) URL syntax. Invalid creates throw TypeError; invalid/unknown/spent/expired/malformed selected consumes return null and retire selected unusable data. Backend/configuration/accounting/wrong-type failures throw, never successful grants. Injected application `now` is not used for ticket expiry. URL validation is repeated after native atomic retirement; invalid destinations never become grants.
184
+
185
+ The existing cached-script runner performs only its definite NOSCRIPT load/retry; arbitrary transport failures are not retried and may follow commit. Provision script ACLs for **SET/PXAT and PERSIST**, as well as TIME/TYPE/GET/DEL/ZSCORE/ZRANGEBYSCORE/ZREM/ZCARD/ZADD/ZREVRANGE/PEXPIREAT. Client connection/shutdown stays caller-owned; standalone and acquired/wrapped Sentinel master scope remains, with no Cluster support added.
186
+
187
+ The final URL can retain an ID-token hint; hashing does not encrypt it. No raw ticket, full session/profile, handle/subject or separate tokens are stored. Exclude records/URLs and arbitrary backend errors from logs. Session/lineage/backchannel deletion does not cascade to authorized tickets. Existing identity, alias, rotation, replay and failover-durability boundaries remain intact.
188
+
175
189
  ## Per-Request DPoP Replay And Capacity
176
190
 
177
191
  | Redis option | Default | Contract |
package/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } 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
@@ -59,6 +59,8 @@ interface OidcVaultRedisClient {
59
59
  * `disconnect()`). The package never calls `connect`, `quit`, or `disconnect`.
60
60
  */
61
61
  interface RedisOidcVaultStoreOptions {
62
+ /** Independent ticket cap across effective namespaces per keyPrefix; positive safe integer, default 10000. */
63
+ logoutNavigationMaxEntries?: number;
62
64
  /** Positive safe integer per shared keyPrefix; default 100000. Configure identically on all clients. */
63
65
  dpopReplayMaxEntries?: number;
64
66
  /** Connected Redis client or compatible adapter implementing {@link OidcVaultRedisClient}. */
@@ -92,10 +94,12 @@ interface RedisOidcVaultStoreOptions {
92
94
  * not supported and is rejected here. Guarded consumes and per-proof replay use
93
95
  * cached atomic Lua; replay is shared per keyPrefix, uses server TIME, and never
94
96
  * evicts live entries. All clients must use the same dpopReplayMaxEntries.
97
+ * Dedicated logout continuation tickets also use native atomic scripts and
98
+ * server TIME, with independent logoutNavigationMaxEntries (default 10000).
95
99
  *
96
100
  * @param options construction options; see {@link RedisOidcVaultStoreOptions}.
97
- * @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
101
+ * @returns a provider satisfying both device-binding and logout-navigation capabilities.
98
102
  */
99
- declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
103
+ declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
100
104
 
101
105
  export { type OidcVaultRedisClient, OidcVaultRedisStoreRecordError, type RedisOidcVaultStoreOptions, createRedisOidcVaultStore };
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } 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
@@ -59,6 +59,8 @@ interface OidcVaultRedisClient {
59
59
  * `disconnect()`). The package never calls `connect`, `quit`, or `disconnect`.
60
60
  */
61
61
  interface RedisOidcVaultStoreOptions {
62
+ /** Independent ticket cap across effective namespaces per keyPrefix; positive safe integer, default 10000. */
63
+ logoutNavigationMaxEntries?: number;
62
64
  /** Positive safe integer per shared keyPrefix; default 100000. Configure identically on all clients. */
63
65
  dpopReplayMaxEntries?: number;
64
66
  /** Connected Redis client or compatible adapter implementing {@link OidcVaultRedisClient}. */
@@ -92,10 +94,12 @@ interface RedisOidcVaultStoreOptions {
92
94
  * not supported and is rejected here. Guarded consumes and per-proof replay use
93
95
  * cached atomic Lua; replay is shared per keyPrefix, uses server TIME, and never
94
96
  * evicts live entries. All clients must use the same dpopReplayMaxEntries.
97
+ * Dedicated logout continuation tickets also use native atomic scripts and
98
+ * server TIME, with independent logoutNavigationMaxEntries (default 10000).
95
99
  *
96
100
  * @param options construction options; see {@link RedisOidcVaultStoreOptions}.
97
- * @returns a provider satisfying the stronger `OidcVaultDeviceBindingStoreProvider` contract.
101
+ * @returns a provider satisfying both device-binding and logout-navigation capabilities.
98
102
  */
99
- declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
103
+ declare function createRedisOidcVaultStore(options: RedisOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
100
104
 
101
105
  export { type OidcVaultRedisClient, OidcVaultRedisStoreRecordError, type RedisOidcVaultStoreOptions, createRedisOidcVaultStore };
package/index.js CHANGED
@@ -70,6 +70,18 @@ var RedisOidcVaultStoreKeys = class {
70
70
  dpopProofs() {
71
71
  return `${this.keyPrefix}:dpop-proofs`;
72
72
  }
73
+ logoutNavigationTicket(id) {
74
+ return this.logoutNavigationTicketPrefix() + id;
75
+ }
76
+ logoutNavigationTicketPrefix() {
77
+ return `${this.keyPrefix}:logout-navigation-ticket:`;
78
+ }
79
+ logoutNavigationExpiries() {
80
+ return `${this.keyPrefix}:logout-navigation-expiries`;
81
+ }
82
+ logoutNavigationCapacity() {
83
+ return `${this.keyPrefix}:logout-navigation-capacity`;
84
+ }
73
85
  rotatedSessionAliasIndex(logicalSessionId) {
74
86
  return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
75
87
  }
@@ -87,6 +99,160 @@ var RedisOidcVaultStoreKeys = class {
87
99
  }
88
100
  };
89
101
 
102
+ // src/logout-ticket-validation.ts
103
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
104
+ var PREFIX = "logout-navigation:v1:";
105
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
106
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
107
+ var captureTicketIdentity = (input) => ({
108
+ namespace: input?.namespace,
109
+ ticketHash: input?.ticketHash
110
+ });
111
+ var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
112
+ var captureTicket = (input) => ({
113
+ ...captureTicketIdentity(input),
114
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
115
+ createdAt: input?.createdAt,
116
+ expiresAt: input?.expiresAt
117
+ });
118
+ var validTicket = (record, now) => {
119
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
120
+ return false;
121
+ try {
122
+ const url = new URL(record.upstreamLogoutUrl);
123
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+ var assertTicket = (record, now) => {
129
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
130
+ };
131
+ var ticketLimit = (value) => {
132
+ const max = value === void 0 ? 1e4 : value;
133
+ if (!Number.isSafeInteger(max) || max <= 0)
134
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
135
+ return max;
136
+ };
137
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
138
+
139
+ // src/logout-tickets.ts
140
+ var LOGOUT_NAVIGATION_TICKET_SCRIPT = `
141
+ local function epoch(value)
142
+ return type(value) == 'number' and value == value and math.abs(value) <= 8640000000000000 and value == math.floor(value)
143
+ end
144
+ local function hash(value)
145
+ return type(value) == 'string' and #value == 43 and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
146
+ and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
147
+ end
148
+ local function identity(member)
149
+ return type(member) == 'string' and string.sub(member, 1, 21) == 'logout-navigation:v1:'
150
+ and #member == 108 and string.sub(member, 65, 65) == ':'
151
+ and hash(string.sub(member, 22, 64)) and hash(string.sub(member, 66))
152
+ end
153
+ local function keyType(key, expected)
154
+ local kind = redis.call('TYPE', key)['ok']
155
+ if kind ~= 'none' and kind ~= expected then error('OIDC vault Redis logout navigation key has unexpected type') end
156
+ end
157
+ local function accountingError()
158
+ error('OIDC vault Redis logout navigation configuration/accounting is inconsistent')
159
+ end
160
+ local function record(raw, member, now)
161
+ local ok, value = pcall(cjson.decode, raw)
162
+ if not ok or type(value) ~= 'table' then return nil end
163
+ local count = 0
164
+ for key, _ in pairs(value) do
165
+ if key ~= 'namespace' and key ~= 'ticketHash' and key ~= 'upstreamLogoutUrl' and key ~= 'createdAt' and key ~= 'expiresAt' then return nil end
166
+ count = count + 1
167
+ end
168
+ if count ~= 5 or type(value['namespace']) ~= 'string' or type(value['ticketHash']) ~= 'string'
169
+ or value['namespace'] .. ':' .. value['ticketHash'] ~= member
170
+ or not epoch(value['createdAt']) or not epoch(value['expiresAt'])
171
+ or value['expiresAt'] - value['createdAt'] < 1000 or value['expiresAt'] - value['createdAt'] > 120000
172
+ or value['expiresAt'] <= now or value['expiresAt'] - now > 120000
173
+ or type(value['upstreamLogoutUrl']) ~= 'string' or #value['upstreamLogoutUrl'] > 16384 then return nil end
174
+ return value
175
+ end
176
+ local mode, member, prefix, max = ARGV[1], ARGV[2], ARGV[3], tonumber(ARGV[4])
177
+ if not identity(member) then error('OIDC vault Redis logout navigation identity is invalid') end
178
+ local time = redis.call('TIME')
179
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
180
+ if not epoch(now) then error('OIDC vault Redis logout navigation store clock is invalid') end
181
+ local incoming = nil
182
+ if mode == 'create' then
183
+ incoming = record(ARGV[5], member, now)
184
+ if incoming == nil then return -1 end
185
+ end
186
+ keyType(KEYS[1], 'string')
187
+ keyType(KEYS[2], 'zset')
188
+ keyType(KEYS[3], 'string')
189
+ local config = redis.call('GET', KEYS[3])
190
+ local count = redis.call('ZCARD', KEYS[2])
191
+ if (config ~= false and config ~= ARGV[4]) or count > max or (config == false and count > 0) then accountingError() end
192
+ local latest = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
193
+ if #latest > 0 and (not identity(latest[1]) or not epoch(tonumber(latest[2]))) then accountingError() end
194
+ local raw = redis.call('GET', KEYS[1])
195
+ local score = redis.call('ZSCORE', KEYS[2], member)
196
+ if score ~= false and not epoch(tonumber(score)) then accountingError() end
197
+ if raw ~= false and (score == false or config == false) then accountingError() end
198
+ local selected = raw ~= false and record(raw, member, now) or nil
199
+ if selected ~= nil and selected['expiresAt'] ~= tonumber(score) then accountingError() end
200
+ -- A still-live ledger protects even an early-disappeared payload from reuse.
201
+ if mode == 'create' and score ~= false and tonumber(score) > now and (raw == false or selected ~= nil) then return 2 end
202
+ local expired = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'WITHSCORES', 'LIMIT', 0, 65)
203
+ local cleanup = {}
204
+ for i = 1, #expired, 2 do
205
+ local other, deadline = expired[i], tonumber(expired[i + 1])
206
+ if other ~= member and #cleanup < 64 then
207
+ if not identity(other) or not epoch(deadline) then accountingError() end
208
+ local key = prefix .. other
209
+ keyType(key, 'string')
210
+ local otherRaw = redis.call('GET', key)
211
+ if otherRaw ~= false then
212
+ local otherRecord = record(otherRaw, other, now)
213
+ if otherRecord ~= nil then accountingError() end
214
+ end
215
+ table.insert(cleanup, other)
216
+ end
217
+ end
218
+ -- All type/config/accounting validation has completed. No unbounded work follows.
219
+ for _, other in ipairs(cleanup) do
220
+ redis.call('DEL', prefix .. other)
221
+ redis.call('ZREM', KEYS[2], other)
222
+ end
223
+ if score ~= false then redis.call('ZREM', KEYS[2], member) end
224
+ if raw ~= false then redis.call('DEL', KEYS[1]) end
225
+ if mode == 'create' then
226
+ if redis.call('ZCARD', KEYS[2]) >= max then return 3 end
227
+ if config == false then redis.call('SET', KEYS[3], ARGV[4]) end
228
+ redis.call('SET', KEYS[1], ARGV[5], 'PXAT', string.format('%.0f', incoming['expiresAt']))
229
+ redis.call('ZADD', KEYS[2], incoming['expiresAt'], member)
230
+ end
231
+ local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
232
+ if #last > 0 then
233
+ if tonumber(last[2]) > now then redis.call('PEXPIREAT', KEYS[2], string.format('%.0f', tonumber(last[2])))
234
+ else redis.call('PERSIST', KEYS[2]) end
235
+ end
236
+ if mode == 'create' then return 1 end
237
+ -- URL syntax is also revalidated by the caller after atomic retirement; invalid
238
+ -- payloads are burned, never restored or redirected. No read/delete gap exists.
239
+ if selected ~= nil then return raw end
240
+ return false
241
+ `;
242
+ var buildLogoutNavigationTicketCommand = (keys, input, maxEntries, creating) => [
243
+ "EVAL",
244
+ LOGOUT_NAVIGATION_TICKET_SCRIPT,
245
+ "3",
246
+ keys.logoutNavigationTicket(ticketId(input)),
247
+ keys.logoutNavigationExpiries(),
248
+ keys.logoutNavigationCapacity(),
249
+ creating ? "create" : "consume",
250
+ ticketId(input),
251
+ keys.logoutNavigationTicketPrefix(),
252
+ String(maxEntries),
253
+ creating ? JSON.stringify(creating) : ""
254
+ ];
255
+
90
256
  // src/records.ts
91
257
  var serialize = (value) => JSON.stringify(value);
92
258
  var OidcVaultRedisStoreRecordError = class extends Error {
@@ -960,7 +1126,9 @@ var RedisOidcVaultStore = class {
960
1126
  scriptRunner;
961
1127
  indexCleanupCursor = "0";
962
1128
  dpopReplayMaxEntries;
1129
+ logoutNavigationMaxEntries;
963
1130
  constructor(options) {
1131
+ this.logoutNavigationMaxEntries = ticketLimit(options.logoutNavigationMaxEntries);
964
1132
  this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
965
1133
  if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
966
1134
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
@@ -1065,6 +1233,34 @@ var RedisOidcVaultStore = class {
1065
1233
  if (result === 2 || result === "2") throw new import_express_oidc_vault.OidcVaultDpopReplayCapacityError();
1066
1234
  return result === 1 || result === "1";
1067
1235
  }
1236
+ async createLogoutNavigationTicket(input) {
1237
+ const record = captureTicket(input);
1238
+ assertTicket(record);
1239
+ const result = await this.runScript(
1240
+ buildLogoutNavigationTicketCommand(this.keys, record, this.logoutNavigationMaxEntries, record)
1241
+ );
1242
+ if (result === -1) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
1243
+ if (result === 2) throw new import_express_oidc_vault.OidcVaultStoreConflictError();
1244
+ if (result === 3) throw new import_express_oidc_vault.OidcVaultLogoutNavigationCapacityError();
1245
+ if (result !== 1) throw new Error("OIDC vault Redis logout navigation create returned an unexpected result.");
1246
+ }
1247
+ async consumeLogoutNavigationTicket(input) {
1248
+ const identity = captureTicketIdentity(input);
1249
+ if (!validTicketIdentity(identity)) return null;
1250
+ const raw = await this.runScript(
1251
+ buildLogoutNavigationTicketCommand(this.keys, identity, this.logoutNavigationMaxEntries)
1252
+ );
1253
+ if (raw === null || raw === false) return null;
1254
+ if (typeof raw !== "string")
1255
+ throw new Error("OIDC vault Redis logout navigation consume returned an unexpected result.");
1256
+ let record;
1257
+ try {
1258
+ record = JSON.parse(raw);
1259
+ } catch {
1260
+ return null;
1261
+ }
1262
+ return validTicket(record) && record.namespace === identity.namespace && record.ticketHash === identity.ticketHash ? captureTicket(record) : null;
1263
+ }
1068
1264
  /**
1069
1265
  * Creates a session record atomically, then runs best-effort index
1070
1266
  * maintenance.
package/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/index.ts
2
2
  import {
3
3
  OidcVaultStoreConflictError,
4
- OidcVaultDpopReplayCapacityError
4
+ OidcVaultDpopReplayCapacityError,
5
+ OidcVaultLogoutNavigationCapacityError
5
6
  } from "@web-ts-toolkit/express-oidc-vault";
6
7
 
7
8
  // src/keys.ts
@@ -48,6 +49,18 @@ var RedisOidcVaultStoreKeys = class {
48
49
  dpopProofs() {
49
50
  return `${this.keyPrefix}:dpop-proofs`;
50
51
  }
52
+ logoutNavigationTicket(id) {
53
+ return this.logoutNavigationTicketPrefix() + id;
54
+ }
55
+ logoutNavigationTicketPrefix() {
56
+ return `${this.keyPrefix}:logout-navigation-ticket:`;
57
+ }
58
+ logoutNavigationExpiries() {
59
+ return `${this.keyPrefix}:logout-navigation-expiries`;
60
+ }
61
+ logoutNavigationCapacity() {
62
+ return `${this.keyPrefix}:logout-navigation-capacity`;
63
+ }
51
64
  rotatedSessionAliasIndex(logicalSessionId) {
52
65
  return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
53
66
  }
@@ -65,6 +78,160 @@ var RedisOidcVaultStoreKeys = class {
65
78
  }
66
79
  };
67
80
 
81
+ // src/logout-ticket-validation.ts
82
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
83
+ var PREFIX = "logout-navigation:v1:";
84
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
85
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
86
+ var captureTicketIdentity = (input) => ({
87
+ namespace: input?.namespace,
88
+ ticketHash: input?.ticketHash
89
+ });
90
+ var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
91
+ var captureTicket = (input) => ({
92
+ ...captureTicketIdentity(input),
93
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
94
+ createdAt: input?.createdAt,
95
+ expiresAt: input?.expiresAt
96
+ });
97
+ var validTicket = (record, now) => {
98
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
99
+ return false;
100
+ try {
101
+ const url = new URL(record.upstreamLogoutUrl);
102
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
103
+ } catch {
104
+ return false;
105
+ }
106
+ };
107
+ var assertTicket = (record, now) => {
108
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
109
+ };
110
+ var ticketLimit = (value) => {
111
+ const max = value === void 0 ? 1e4 : value;
112
+ if (!Number.isSafeInteger(max) || max <= 0)
113
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
114
+ return max;
115
+ };
116
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
117
+
118
+ // src/logout-tickets.ts
119
+ var LOGOUT_NAVIGATION_TICKET_SCRIPT = `
120
+ local function epoch(value)
121
+ return type(value) == 'number' and value == value and math.abs(value) <= 8640000000000000 and value == math.floor(value)
122
+ end
123
+ local function hash(value)
124
+ return type(value) == 'string' and #value == 43 and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
125
+ and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
126
+ end
127
+ local function identity(member)
128
+ return type(member) == 'string' and string.sub(member, 1, 21) == 'logout-navigation:v1:'
129
+ and #member == 108 and string.sub(member, 65, 65) == ':'
130
+ and hash(string.sub(member, 22, 64)) and hash(string.sub(member, 66))
131
+ end
132
+ local function keyType(key, expected)
133
+ local kind = redis.call('TYPE', key)['ok']
134
+ if kind ~= 'none' and kind ~= expected then error('OIDC vault Redis logout navigation key has unexpected type') end
135
+ end
136
+ local function accountingError()
137
+ error('OIDC vault Redis logout navigation configuration/accounting is inconsistent')
138
+ end
139
+ local function record(raw, member, now)
140
+ local ok, value = pcall(cjson.decode, raw)
141
+ if not ok or type(value) ~= 'table' then return nil end
142
+ local count = 0
143
+ for key, _ in pairs(value) do
144
+ if key ~= 'namespace' and key ~= 'ticketHash' and key ~= 'upstreamLogoutUrl' and key ~= 'createdAt' and key ~= 'expiresAt' then return nil end
145
+ count = count + 1
146
+ end
147
+ if count ~= 5 or type(value['namespace']) ~= 'string' or type(value['ticketHash']) ~= 'string'
148
+ or value['namespace'] .. ':' .. value['ticketHash'] ~= member
149
+ or not epoch(value['createdAt']) or not epoch(value['expiresAt'])
150
+ or value['expiresAt'] - value['createdAt'] < 1000 or value['expiresAt'] - value['createdAt'] > 120000
151
+ or value['expiresAt'] <= now or value['expiresAt'] - now > 120000
152
+ or type(value['upstreamLogoutUrl']) ~= 'string' or #value['upstreamLogoutUrl'] > 16384 then return nil end
153
+ return value
154
+ end
155
+ local mode, member, prefix, max = ARGV[1], ARGV[2], ARGV[3], tonumber(ARGV[4])
156
+ if not identity(member) then error('OIDC vault Redis logout navigation identity is invalid') end
157
+ local time = redis.call('TIME')
158
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
159
+ if not epoch(now) then error('OIDC vault Redis logout navigation store clock is invalid') end
160
+ local incoming = nil
161
+ if mode == 'create' then
162
+ incoming = record(ARGV[5], member, now)
163
+ if incoming == nil then return -1 end
164
+ end
165
+ keyType(KEYS[1], 'string')
166
+ keyType(KEYS[2], 'zset')
167
+ keyType(KEYS[3], 'string')
168
+ local config = redis.call('GET', KEYS[3])
169
+ local count = redis.call('ZCARD', KEYS[2])
170
+ if (config ~= false and config ~= ARGV[4]) or count > max or (config == false and count > 0) then accountingError() end
171
+ local latest = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
172
+ if #latest > 0 and (not identity(latest[1]) or not epoch(tonumber(latest[2]))) then accountingError() end
173
+ local raw = redis.call('GET', KEYS[1])
174
+ local score = redis.call('ZSCORE', KEYS[2], member)
175
+ if score ~= false and not epoch(tonumber(score)) then accountingError() end
176
+ if raw ~= false and (score == false or config == false) then accountingError() end
177
+ local selected = raw ~= false and record(raw, member, now) or nil
178
+ if selected ~= nil and selected['expiresAt'] ~= tonumber(score) then accountingError() end
179
+ -- A still-live ledger protects even an early-disappeared payload from reuse.
180
+ if mode == 'create' and score ~= false and tonumber(score) > now and (raw == false or selected ~= nil) then return 2 end
181
+ local expired = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'WITHSCORES', 'LIMIT', 0, 65)
182
+ local cleanup = {}
183
+ for i = 1, #expired, 2 do
184
+ local other, deadline = expired[i], tonumber(expired[i + 1])
185
+ if other ~= member and #cleanup < 64 then
186
+ if not identity(other) or not epoch(deadline) then accountingError() end
187
+ local key = prefix .. other
188
+ keyType(key, 'string')
189
+ local otherRaw = redis.call('GET', key)
190
+ if otherRaw ~= false then
191
+ local otherRecord = record(otherRaw, other, now)
192
+ if otherRecord ~= nil then accountingError() end
193
+ end
194
+ table.insert(cleanup, other)
195
+ end
196
+ end
197
+ -- All type/config/accounting validation has completed. No unbounded work follows.
198
+ for _, other in ipairs(cleanup) do
199
+ redis.call('DEL', prefix .. other)
200
+ redis.call('ZREM', KEYS[2], other)
201
+ end
202
+ if score ~= false then redis.call('ZREM', KEYS[2], member) end
203
+ if raw ~= false then redis.call('DEL', KEYS[1]) end
204
+ if mode == 'create' then
205
+ if redis.call('ZCARD', KEYS[2]) >= max then return 3 end
206
+ if config == false then redis.call('SET', KEYS[3], ARGV[4]) end
207
+ redis.call('SET', KEYS[1], ARGV[5], 'PXAT', string.format('%.0f', incoming['expiresAt']))
208
+ redis.call('ZADD', KEYS[2], incoming['expiresAt'], member)
209
+ end
210
+ local last = redis.call('ZREVRANGE', KEYS[2], 0, 0, 'WITHSCORES')
211
+ if #last > 0 then
212
+ if tonumber(last[2]) > now then redis.call('PEXPIREAT', KEYS[2], string.format('%.0f', tonumber(last[2])))
213
+ else redis.call('PERSIST', KEYS[2]) end
214
+ end
215
+ if mode == 'create' then return 1 end
216
+ -- URL syntax is also revalidated by the caller after atomic retirement; invalid
217
+ -- payloads are burned, never restored or redirected. No read/delete gap exists.
218
+ if selected ~= nil then return raw end
219
+ return false
220
+ `;
221
+ var buildLogoutNavigationTicketCommand = (keys, input, maxEntries, creating) => [
222
+ "EVAL",
223
+ LOGOUT_NAVIGATION_TICKET_SCRIPT,
224
+ "3",
225
+ keys.logoutNavigationTicket(ticketId(input)),
226
+ keys.logoutNavigationExpiries(),
227
+ keys.logoutNavigationCapacity(),
228
+ creating ? "create" : "consume",
229
+ ticketId(input),
230
+ keys.logoutNavigationTicketPrefix(),
231
+ String(maxEntries),
232
+ creating ? JSON.stringify(creating) : ""
233
+ ];
234
+
68
235
  // src/records.ts
69
236
  var serialize = (value) => JSON.stringify(value);
70
237
  var OidcVaultRedisStoreRecordError = class extends Error {
@@ -938,7 +1105,9 @@ var RedisOidcVaultStore = class {
938
1105
  scriptRunner;
939
1106
  indexCleanupCursor = "0";
940
1107
  dpopReplayMaxEntries;
1108
+ logoutNavigationMaxEntries;
941
1109
  constructor(options) {
1110
+ this.logoutNavigationMaxEntries = ticketLimit(options.logoutNavigationMaxEntries);
942
1111
  this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
943
1112
  if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
944
1113
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
@@ -1043,6 +1212,34 @@ var RedisOidcVaultStore = class {
1043
1212
  if (result === 2 || result === "2") throw new OidcVaultDpopReplayCapacityError();
1044
1213
  return result === 1 || result === "1";
1045
1214
  }
1215
+ async createLogoutNavigationTicket(input) {
1216
+ const record = captureTicket(input);
1217
+ assertTicket(record);
1218
+ const result = await this.runScript(
1219
+ buildLogoutNavigationTicketCommand(this.keys, record, this.logoutNavigationMaxEntries, record)
1220
+ );
1221
+ if (result === -1) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
1222
+ if (result === 2) throw new OidcVaultStoreConflictError();
1223
+ if (result === 3) throw new OidcVaultLogoutNavigationCapacityError();
1224
+ if (result !== 1) throw new Error("OIDC vault Redis logout navigation create returned an unexpected result.");
1225
+ }
1226
+ async consumeLogoutNavigationTicket(input) {
1227
+ const identity = captureTicketIdentity(input);
1228
+ if (!validTicketIdentity(identity)) return null;
1229
+ const raw = await this.runScript(
1230
+ buildLogoutNavigationTicketCommand(this.keys, identity, this.logoutNavigationMaxEntries)
1231
+ );
1232
+ if (raw === null || raw === false) return null;
1233
+ if (typeof raw !== "string")
1234
+ throw new Error("OIDC vault Redis logout navigation consume returned an unexpected result.");
1235
+ let record;
1236
+ try {
1237
+ record = JSON.parse(raw);
1238
+ } catch {
1239
+ return null;
1240
+ }
1241
+ return validTicket(record) && record.namespace === identity.namespace && record.ticketHash === identity.ticketHash ? captureTicket(record) : null;
1242
+ }
1046
1243
  /**
1047
1244
  * Creates a session record atomically, then runs best-effort index
1048
1245
  * maintenance.
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.52.0",
5
+ "version": "0.54.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -29,7 +29,7 @@
29
29
  "node": ">=22.12.0"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.52.0"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.54.0"
33
33
  },
34
34
  "files": [
35
35
  "**/*",