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