@web-ts-toolkit/express-oidc-vault-redis-store 0.53.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 +14 -0
- package/index.d.mts +7 -3
- package/index.d.ts +7 -3
- package/index.js +196 -0
- package/index.mjs +198 -1
- package/package.json +2 -2
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
|
|
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
|
|
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.
|
|
5
|
+
"version": "0.54.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"express",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22.12.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@web-ts-toolkit/express-oidc-vault": "0.
|
|
32
|
+
"@web-ts-toolkit/express-oidc-vault": "0.54.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"**/*",
|