@web-ts-toolkit/express-oidc-vault-mongodb-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 +52 -4
- package/index.d.mts +13 -6
- package/index.d.ts +13 -6
- package/index.js +350 -30
- package/index.mjs +350 -30
- package/package.json +8 -4
package/index.mjs
CHANGED
|
@@ -1,21 +1,96 @@
|
|
|
1
1
|
// src/store.ts
|
|
2
2
|
import {
|
|
3
|
-
OidcVaultStoreConflictError
|
|
3
|
+
OidcVaultStoreConflictError as OidcVaultStoreConflictError2
|
|
4
4
|
} from "@web-ts-toolkit/express-oidc-vault";
|
|
5
5
|
import { isDeepStrictEqual } from "util";
|
|
6
6
|
|
|
7
|
+
// src/binding.ts
|
|
8
|
+
import { OidcVaultStoreConflictError } from "@web-ts-toolkit/express-oidc-vault";
|
|
9
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
|
|
11
|
+
var isBinding = (value) => isRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
|
|
12
|
+
var hasValidSessionBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
|
|
13
|
+
var hasValidRecordBinding = (record) => hasValidSessionBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
|
|
14
|
+
var assertRecordBinding = (record) => {
|
|
15
|
+
if (!hasValidRecordBinding(record))
|
|
16
|
+
throw new TypeError("OIDC vault store record has invalid device/browser binding.");
|
|
17
|
+
};
|
|
18
|
+
var assertSessionBinding = (record) => {
|
|
19
|
+
if (!hasValidSessionBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
|
|
20
|
+
};
|
|
21
|
+
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));
|
|
22
|
+
var bindingMatchFilter = (match) => ({
|
|
23
|
+
...match.deviceBinding === null ? { deviceBinding: { $exists: false } } : {
|
|
24
|
+
"deviceBinding.type": match.deviceBinding.type,
|
|
25
|
+
"deviceBinding.jkt": match.deviceBinding.jkt,
|
|
26
|
+
$expr: {
|
|
27
|
+
$eq: [
|
|
28
|
+
{
|
|
29
|
+
$size: {
|
|
30
|
+
$objectToArray: { $cond: [{ $eq: [{ $type: "$deviceBinding" }, "object"] }, "$deviceBinding", {}] }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
2
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
browserBindingHash: match.browserBindingHash === null ? { $exists: false } : match.browserBindingHash
|
|
38
|
+
});
|
|
39
|
+
var inheritSessionBinding = (source, next) => {
|
|
40
|
+
assertSessionBinding(source);
|
|
41
|
+
assertSessionBinding(next);
|
|
42
|
+
if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
|
|
43
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
|
|
44
|
+
}
|
|
45
|
+
if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
|
|
46
|
+
};
|
|
47
|
+
var toRevocationContext = (session) => {
|
|
48
|
+
assertSessionBinding(session);
|
|
49
|
+
const provider = session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider;
|
|
50
|
+
const expiry = session.deviceBinding === void 0 && session.expiresAt === null ? void 0 : session.expiresAt;
|
|
51
|
+
if (typeof session._id !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string" || expiry !== void 0 && (!(expiry instanceof Date) || !Number.isFinite(expiry.getTime())) || provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
|
|
52
|
+
throw new Error("OIDC vault MongoDB lineage has malformed session/provider authority.");
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
56
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
57
|
+
...provider === void 0 ? {} : {
|
|
58
|
+
provider: {
|
|
59
|
+
...provider.issuer === void 0 ? {} : { issuer: provider.issuer },
|
|
60
|
+
...provider.clientId === void 0 ? {} : { clientId: provider.clientId }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
var assertSameLineageAuthority = (source, member) => {
|
|
66
|
+
toRevocationContext(member);
|
|
67
|
+
if (source.deviceBinding?.jkt !== member.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== member.provider?.issuer || source.provider?.clientId !== member.provider?.clientId) {
|
|
68
|
+
throw new Error("OIDC vault MongoDB lineage has inconsistent device binding or provider identity.");
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
7
72
|
// src/documents.ts
|
|
73
|
+
var providerToDocument = (provider) => {
|
|
74
|
+
if (provider === null || typeof provider !== "object") return provider;
|
|
75
|
+
const prototype = Object.getPrototypeOf(provider);
|
|
76
|
+
if (prototype !== Object.prototype && prototype !== null) return provider;
|
|
77
|
+
const copy = { ...provider };
|
|
78
|
+
if (copy.issuer === void 0) delete copy.issuer;
|
|
79
|
+
if (copy.clientId === void 0) delete copy.clientId;
|
|
80
|
+
return copy;
|
|
81
|
+
};
|
|
8
82
|
var sessionToDocument = (session) => ({
|
|
9
83
|
_id: session.sessionId,
|
|
10
84
|
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
11
85
|
subject: session.subject,
|
|
12
86
|
providerSessionId: session.providerSessionId,
|
|
13
|
-
provider: session.provider,
|
|
87
|
+
...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
|
|
88
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
14
89
|
refreshToken: session.refreshToken,
|
|
15
90
|
idToken: session.idToken,
|
|
16
91
|
accessToken: session.accessToken,
|
|
17
92
|
scope: session.scope,
|
|
18
|
-
|
|
93
|
+
...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
|
|
19
94
|
createdAt: session.createdAt,
|
|
20
95
|
updatedAt: session.updatedAt,
|
|
21
96
|
user: session.user,
|
|
@@ -26,7 +101,8 @@ var documentToSession = (session) => ({
|
|
|
26
101
|
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
27
102
|
subject: session.subject,
|
|
28
103
|
providerSessionId: session.providerSessionId,
|
|
29
|
-
provider: session.provider,
|
|
104
|
+
provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
|
|
105
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
30
106
|
refreshToken: session.refreshToken,
|
|
31
107
|
idToken: session.idToken,
|
|
32
108
|
accessToken: session.accessToken,
|
|
@@ -37,22 +113,26 @@ var documentToSession = (session) => ({
|
|
|
37
113
|
user: session.user,
|
|
38
114
|
metadata: session.metadata
|
|
39
115
|
});
|
|
40
|
-
var authorizationTransactionToDocument = (record) =>
|
|
41
|
-
_id: record.state,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
...record,
|
|
48
|
-
|
|
49
|
-
|
|
116
|
+
var authorizationTransactionToDocument = (record) => {
|
|
117
|
+
const document = { _id: record.state, ...record, expiresAt: new Date(record.expiresAt) };
|
|
118
|
+
if (document.deviceBinding === void 0) delete document.deviceBinding;
|
|
119
|
+
if (document.browserBindingHash === void 0) delete document.browserBindingHash;
|
|
120
|
+
return document;
|
|
121
|
+
};
|
|
122
|
+
var exchangeCodeToDocument = (record) => {
|
|
123
|
+
const document = { _id: record.code, ...record, expiresAt: new Date(record.expiresAt) };
|
|
124
|
+
if (document.deviceBinding === void 0) delete document.deviceBinding;
|
|
125
|
+
if (document.browserBindingHash === void 0) delete document.browserBindingHash;
|
|
126
|
+
return document;
|
|
127
|
+
};
|
|
50
128
|
var authorizationDocumentToRecord = (record) => ({
|
|
51
129
|
state: record.state,
|
|
52
130
|
nonce: record.nonce,
|
|
53
131
|
pkceVerifier: record.pkceVerifier,
|
|
54
132
|
codeChallenge: record.codeChallenge,
|
|
55
133
|
returnTo: record.returnTo,
|
|
134
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
135
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
56
136
|
createdAt: record.createdAt,
|
|
57
137
|
expiresAt: record.expiresAt.getTime(),
|
|
58
138
|
metadata: record.metadata
|
|
@@ -61,10 +141,17 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
61
141
|
code: record.code,
|
|
62
142
|
sessionId: record.sessionId,
|
|
63
143
|
returnTo: record.returnTo,
|
|
144
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
145
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
64
146
|
createdAt: record.createdAt,
|
|
65
147
|
expiresAt: record.expiresAt.getTime()
|
|
66
148
|
});
|
|
67
149
|
var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
|
|
150
|
+
var validDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
151
|
+
var optionalString = (value) => value === void 0 || typeof value === "string";
|
|
152
|
+
var validAuthorizationDocument = (record, state) => record._id === state && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
|
|
153
|
+
var validExchangeDocument = (record, code) => record._id === code && record.code === code && typeof record.sessionId === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
|
|
154
|
+
var validSessionDocument = (record, sessionId) => record._id === sessionId && typeof record.subject === "string" && optionalString(record.logicalSessionId) && typeof record.refreshToken === "string" && typeof record.idToken === "string" && (record.expiresAt === void 0 || record.deviceBinding === void 0 && record.expiresAt === null || validDate(record.expiresAt)) && hasValidSessionBinding(record);
|
|
68
155
|
|
|
69
156
|
// src/options.ts
|
|
70
157
|
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
@@ -73,7 +160,9 @@ var DEFAULT_COLLECTION_NAMES = {
|
|
|
73
160
|
exchangeCodes: "oidc_vault_exchange_codes",
|
|
74
161
|
sessions: "oidc_vault_sessions",
|
|
75
162
|
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
76
|
-
rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
|
|
163
|
+
rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
|
|
164
|
+
dpopProofs: "oidc_vault_dpop_proofs",
|
|
165
|
+
dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
|
|
77
166
|
};
|
|
78
167
|
var validateCollectionName = (role, name) => {
|
|
79
168
|
if (name.length === 0) {
|
|
@@ -95,7 +184,9 @@ var resolveCollectionNames = (options) => {
|
|
|
95
184
|
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
96
185
|
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
97
186
|
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
98
|
-
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
|
|
187
|
+
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
|
|
188
|
+
dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
|
|
189
|
+
dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
|
|
99
190
|
};
|
|
100
191
|
for (const [role, name] of Object.entries(collectionNames)) {
|
|
101
192
|
validateCollectionName(role, name);
|
|
@@ -137,6 +228,10 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
137
228
|
createTtlIndex(collections.sessions),
|
|
138
229
|
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
139
230
|
createTtlIndex(collections.rotatedSessionAliases),
|
|
231
|
+
createTtlIndex(collections.dpopProofs),
|
|
232
|
+
// Accounting outlives physical proof TTL deletion until a serialized
|
|
233
|
+
// bounded cleanup decrements capacity. Never TTL-delete these entries.
|
|
234
|
+
collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
|
|
140
235
|
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
141
236
|
collections.sessions.createIndex(
|
|
142
237
|
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
@@ -150,6 +245,120 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
150
245
|
]);
|
|
151
246
|
};
|
|
152
247
|
|
|
248
|
+
// src/dpop-replay.ts
|
|
249
|
+
import { OidcVaultDpopReplayCapacityError } from "@web-ts-toolkit/express-oidc-vault";
|
|
250
|
+
var CAPACITY_ID = "capacity";
|
|
251
|
+
var reservationId = (replayKey) => `proof:${replayKey}`;
|
|
252
|
+
var isDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
253
|
+
var MongoDpopReplayReservations = class {
|
|
254
|
+
constructor(db, proofs, capacity, maxEntries, now) {
|
|
255
|
+
this.db = db;
|
|
256
|
+
this.proofs = proofs;
|
|
257
|
+
this.capacity = capacity;
|
|
258
|
+
this.maxEntries = maxEntries;
|
|
259
|
+
this.now = now;
|
|
260
|
+
}
|
|
261
|
+
async initialize() {
|
|
262
|
+
const indexes = await this.capacity.listIndexes().toArray();
|
|
263
|
+
if (indexes.some((index) => index.expireAfterSeconds !== void 0)) {
|
|
264
|
+
throw new Error("OIDC vault MongoDB replay capacity accounting must not have TTL indexes.");
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await this.capacity.updateOne(
|
|
268
|
+
{ _id: CAPACITY_ID },
|
|
269
|
+
{
|
|
270
|
+
$setOnInsert: {
|
|
271
|
+
kind: "capacity",
|
|
272
|
+
entries: 0,
|
|
273
|
+
revision: 0,
|
|
274
|
+
maxEntries: this.maxEntries,
|
|
275
|
+
proofsCollectionName: this.proofs.collectionName
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
{ upsert: true }
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
|
|
282
|
+
}
|
|
283
|
+
this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY_ID }));
|
|
284
|
+
}
|
|
285
|
+
async reserve(input) {
|
|
286
|
+
if (!this.validWindow(input, this.now())) return false;
|
|
287
|
+
const session = this.db.client.startSession();
|
|
288
|
+
try {
|
|
289
|
+
const result = await session.withTransaction(
|
|
290
|
+
async () => {
|
|
291
|
+
const now = this.now();
|
|
292
|
+
if (!this.validWindow(input, now)) return false;
|
|
293
|
+
const capacity = await this.capacity.findOneAndUpdate(
|
|
294
|
+
{ _id: CAPACITY_ID },
|
|
295
|
+
{ $inc: { revision: 1 } },
|
|
296
|
+
{ session, returnDocument: "after" }
|
|
297
|
+
);
|
|
298
|
+
this.validateCapacity(capacity);
|
|
299
|
+
const proof = await this.proofs.findOne({ _id: input.replayKey }, { session });
|
|
300
|
+
if (proof && !isDate(proof.expiresAt))
|
|
301
|
+
throw new Error("OIDC vault MongoDB replay proof has malformed expiry.");
|
|
302
|
+
if (proof && proof.expiresAt.getTime() > now) return false;
|
|
303
|
+
const id = reservationId(input.replayKey);
|
|
304
|
+
const existing = await this.capacity.findOne({ _id: id }, { session });
|
|
305
|
+
if (existing) {
|
|
306
|
+
this.validateReservation(existing);
|
|
307
|
+
if (existing.expiresAt.getTime() > now) return false;
|
|
308
|
+
}
|
|
309
|
+
let removed = 0;
|
|
310
|
+
if (existing) removed += (await this.capacity.deleteOne({ _id: id }, { session })).deletedCount;
|
|
311
|
+
const expired = await this.capacity.find({ kind: "reservation", expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
|
|
312
|
+
for (const row of expired) this.validateReservation(row);
|
|
313
|
+
if (expired.length > 0) {
|
|
314
|
+
removed += (await this.capacity.deleteMany(
|
|
315
|
+
{ _id: { $in: expired.map((row) => row._id) }, kind: "reservation" },
|
|
316
|
+
{ session }
|
|
317
|
+
)).deletedCount;
|
|
318
|
+
await this.proofs.deleteMany(
|
|
319
|
+
{ _id: { $in: expired.map((row) => row.replayKey) }, expiresAt: { $lte: new Date(now) } },
|
|
320
|
+
{ session }
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const entries = capacity.entries - removed;
|
|
324
|
+
if (!Number.isSafeInteger(entries) || entries < 0)
|
|
325
|
+
throw new Error("OIDC vault MongoDB replay capacity accounting is inconsistent.");
|
|
326
|
+
if (entries >= this.maxEntries) throw new OidcVaultDpopReplayCapacityError();
|
|
327
|
+
await this.proofs.replaceOne(
|
|
328
|
+
{ _id: input.replayKey },
|
|
329
|
+
{ expiresAt: new Date(input.expiresAt) },
|
|
330
|
+
{ upsert: true, session }
|
|
331
|
+
);
|
|
332
|
+
await this.capacity.insertOne(
|
|
333
|
+
{ _id: id, kind: "reservation", replayKey: input.replayKey, expiresAt: new Date(input.expiresAt) },
|
|
334
|
+
{ session }
|
|
335
|
+
);
|
|
336
|
+
await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
|
|
337
|
+
return true;
|
|
338
|
+
},
|
|
339
|
+
{ readConcern: { level: "snapshot" } }
|
|
340
|
+
);
|
|
341
|
+
return result === true;
|
|
342
|
+
} finally {
|
|
343
|
+
await session.endSession();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
validWindow(input, now) {
|
|
347
|
+
if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
|
|
348
|
+
return typeof input.replayKey === "string" && Number.isSafeInteger(input.expiresAt) && input.expiresAt > now && input.expiresAt - now <= 36e4;
|
|
349
|
+
}
|
|
350
|
+
validateCapacity(row) {
|
|
351
|
+
if (!row || row.kind !== "capacity" || row.maxEntries !== this.maxEntries || row.proofsCollectionName !== this.proofs.collectionName || !Number.isSafeInteger(row.entries) || row.entries < 0 || row.entries > this.maxEntries || !Number.isSafeInteger(row.revision)) {
|
|
352
|
+
throw new Error("OIDC vault MongoDB replay capacity configuration/accounting is inconsistent.");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
validateReservation(row) {
|
|
356
|
+
if (row.kind !== "reservation" || typeof row.replayKey !== "string" || row._id !== reservationId(row.replayKey) || !isDate(row.expiresAt)) {
|
|
357
|
+
throw new Error("OIDC vault MongoDB replay expiry accounting is malformed.");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
153
362
|
// src/store.ts
|
|
154
363
|
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
155
364
|
if (input === null || typeof input !== "object") return input;
|
|
@@ -174,10 +383,17 @@ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: inp
|
|
|
174
383
|
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
175
384
|
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
176
385
|
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
177
|
-
var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && isDeepStrictEqual(
|
|
386
|
+
var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && isDeepStrictEqual(
|
|
387
|
+
current.deviceBinding === void 0 && current.provider === null ? void 0 : current.provider,
|
|
388
|
+
expected.provider
|
|
389
|
+
) && isDeepStrictEqual(current.deviceBinding, expected.deviceBinding) && current.refreshToken === expected.refreshToken && current.idToken === expected.idToken && current.accessToken === expected.accessToken && current.scope === expected.scope && (current.expiresAt?.getTime() ?? null) === (expected.expiresAt?.getTime() ?? null) && current.createdAt === expected.createdAt && current.updatedAt === expected.updatedAt && isDeepStrictEqual(current.user, expected.user) && isDeepStrictEqual(current.metadata, expected.metadata);
|
|
178
390
|
var MongoOidcVaultStore = class {
|
|
179
391
|
constructor(options) {
|
|
180
392
|
const collectionNames = resolveCollectionNames(options);
|
|
393
|
+
const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
|
|
394
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
|
|
395
|
+
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
396
|
+
}
|
|
181
397
|
this.db = options.db;
|
|
182
398
|
this.authorizationTransactions = options.db.collection(
|
|
183
399
|
collectionNames.authorizationTransactions
|
|
@@ -190,11 +406,20 @@ var MongoOidcVaultStore = class {
|
|
|
190
406
|
this.rotatedSessionAliases = options.db.collection(
|
|
191
407
|
collectionNames.rotatedSessionAliases
|
|
192
408
|
);
|
|
409
|
+
this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
|
|
410
|
+
this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
|
|
193
411
|
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
194
412
|
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
195
413
|
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
196
414
|
}
|
|
197
415
|
this.now = options.now ?? (() => Date.now());
|
|
416
|
+
this.dpopReplay = new MongoDpopReplayReservations(
|
|
417
|
+
this.db,
|
|
418
|
+
this.dpopProofs,
|
|
419
|
+
this.dpopReplayCapacity,
|
|
420
|
+
maxEntries,
|
|
421
|
+
this.now
|
|
422
|
+
);
|
|
198
423
|
this.initialization = this.initialize().then(void 0, (error) => {
|
|
199
424
|
this.initializationError = error;
|
|
200
425
|
});
|
|
@@ -204,6 +429,7 @@ var MongoOidcVaultStore = class {
|
|
|
204
429
|
}
|
|
205
430
|
async createAuthorizationTransaction(input) {
|
|
206
431
|
input = snapshotInput({ ...input });
|
|
432
|
+
assertRecordBinding(input);
|
|
207
433
|
await this.waitUntilReady();
|
|
208
434
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
209
435
|
upsert: true
|
|
@@ -211,27 +437,70 @@ var MongoOidcVaultStore = class {
|
|
|
211
437
|
}
|
|
212
438
|
async consumeAuthorizationTransaction(state) {
|
|
213
439
|
await this.waitUntilReady();
|
|
214
|
-
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
215
|
-
|
|
440
|
+
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
441
|
+
_id: state,
|
|
442
|
+
deviceBinding: { $exists: false },
|
|
443
|
+
browserBindingHash: { $exists: false }
|
|
444
|
+
});
|
|
445
|
+
if (!record || !validAuthorizationDocument(record, state) || isExpired(record, this.now())) {
|
|
216
446
|
return null;
|
|
217
447
|
}
|
|
218
448
|
return authorizationDocumentToRecord(record);
|
|
219
449
|
}
|
|
450
|
+
async getAuthorizationTransaction(state) {
|
|
451
|
+
await this.waitUntilReady();
|
|
452
|
+
const record = await this.authorizationTransactions.findOne({ _id: state });
|
|
453
|
+
return record && validAuthorizationDocument(record, state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
|
|
454
|
+
}
|
|
455
|
+
async consumeAuthorizationTransactionIfMatches(input) {
|
|
456
|
+
input = snapshotInput({ ...input });
|
|
457
|
+
if (!isBindingMatch(input.match)) return null;
|
|
458
|
+
await this.waitUntilReady();
|
|
459
|
+
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
460
|
+
_id: input.state,
|
|
461
|
+
expiresAt: { $gt: new Date(this.now()) },
|
|
462
|
+
...bindingMatchFilter(input.match)
|
|
463
|
+
});
|
|
464
|
+
return record && validAuthorizationDocument(record, input.state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
|
|
465
|
+
}
|
|
220
466
|
async createExchangeCode(input) {
|
|
221
|
-
input = { ...input };
|
|
467
|
+
input = snapshotInput({ ...input });
|
|
468
|
+
assertRecordBinding(input);
|
|
222
469
|
await this.waitUntilReady();
|
|
223
470
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
224
471
|
}
|
|
225
472
|
async consumeExchangeCode(code) {
|
|
226
473
|
await this.waitUntilReady();
|
|
227
|
-
const record = await this.exchangeCodes.findOneAndDelete({
|
|
228
|
-
|
|
474
|
+
const record = await this.exchangeCodes.findOneAndDelete({
|
|
475
|
+
_id: code,
|
|
476
|
+
deviceBinding: { $exists: false },
|
|
477
|
+
browserBindingHash: { $exists: false }
|
|
478
|
+
});
|
|
479
|
+
if (!record || !validExchangeDocument(record, code) || isExpired(record, this.now())) {
|
|
229
480
|
return null;
|
|
230
481
|
}
|
|
231
482
|
return exchangeDocumentToRecord(record);
|
|
232
483
|
}
|
|
484
|
+
async getExchangeCode(code) {
|
|
485
|
+
await this.waitUntilReady();
|
|
486
|
+
const record = await this.exchangeCodes.findOne({ _id: code });
|
|
487
|
+
return record && validExchangeDocument(record, code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
|
|
488
|
+
}
|
|
489
|
+
async consumeExchangeCodeIfMatches(input) {
|
|
490
|
+
input = snapshotInput({ ...input });
|
|
491
|
+
if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
|
|
492
|
+
await this.waitUntilReady();
|
|
493
|
+
const record = await this.exchangeCodes.findOneAndDelete({
|
|
494
|
+
_id: input.code,
|
|
495
|
+
sessionId: input.expectedSessionId,
|
|
496
|
+
expiresAt: { $gt: new Date(this.now()) },
|
|
497
|
+
...bindingMatchFilter(input.match)
|
|
498
|
+
});
|
|
499
|
+
return record && validExchangeDocument(record, input.code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
|
|
500
|
+
}
|
|
233
501
|
async createSession(input) {
|
|
234
502
|
input = snapshotInput({ ...input });
|
|
503
|
+
assertSessionBinding(input);
|
|
235
504
|
await this.waitUntilReady();
|
|
236
505
|
const timestamp = this.now();
|
|
237
506
|
const session = {
|
|
@@ -246,22 +515,69 @@ var MongoOidcVaultStore = class {
|
|
|
246
515
|
async getSession(sessionId) {
|
|
247
516
|
await this.waitUntilReady();
|
|
248
517
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
249
|
-
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
518
|
+
if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
250
519
|
return null;
|
|
251
520
|
}
|
|
252
521
|
return documentToSession(session);
|
|
253
522
|
}
|
|
523
|
+
async getSessionRevocationContext(sessionId) {
|
|
524
|
+
await this.waitUntilReady();
|
|
525
|
+
const transaction = this.db.client.startSession();
|
|
526
|
+
const projection = { _id: 1, logicalSessionId: 1, subject: 1, provider: 1, deviceBinding: 1, expiresAt: 1 };
|
|
527
|
+
try {
|
|
528
|
+
return await transaction.withTransaction(
|
|
529
|
+
async () => {
|
|
530
|
+
const now = this.now();
|
|
531
|
+
const live = await this.sessions.findOne({ _id: sessionId }, { session: transaction, projection });
|
|
532
|
+
let logicalSessionId;
|
|
533
|
+
if (live) {
|
|
534
|
+
toRevocationContext(live);
|
|
535
|
+
if (!isExpired(live, now)) logicalSessionId = live.logicalSessionId ?? live._id;
|
|
536
|
+
}
|
|
537
|
+
if (logicalSessionId === void 0) {
|
|
538
|
+
const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId }, { session: transaction });
|
|
539
|
+
if (!alias || isExpired(alias, now)) return null;
|
|
540
|
+
if (typeof alias.logicalSessionId !== "string" || !(alias.expiresAt instanceof Date) || !Number.isFinite(alias.expiresAt.getTime())) {
|
|
541
|
+
throw new Error("OIDC vault MongoDB store has malformed revocation alias.");
|
|
542
|
+
}
|
|
543
|
+
logicalSessionId = alias.logicalSessionId;
|
|
544
|
+
}
|
|
545
|
+
const members = await this.sessions.find(
|
|
546
|
+
{ $or: [{ logicalSessionId }, { logicalSessionId: { $exists: false }, _id: logicalSessionId }] },
|
|
547
|
+
{ session: transaction, projection }
|
|
548
|
+
).toArray();
|
|
549
|
+
let source;
|
|
550
|
+
for (const member of members) {
|
|
551
|
+
toRevocationContext(member);
|
|
552
|
+
if (isExpired(member, now)) continue;
|
|
553
|
+
if (source) assertSameLineageAuthority(source, member);
|
|
554
|
+
else source = member;
|
|
555
|
+
}
|
|
556
|
+
return source ? toRevocationContext(source) : null;
|
|
557
|
+
},
|
|
558
|
+
{ readConcern: { level: "snapshot" } }
|
|
559
|
+
) ?? null;
|
|
560
|
+
} finally {
|
|
561
|
+
await transaction.endSession();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async reserveDpopProof(input) {
|
|
565
|
+
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
566
|
+
await this.waitUntilReady();
|
|
567
|
+
return this.dpopReplay.reserve(input);
|
|
568
|
+
}
|
|
254
569
|
async rotateSession(input) {
|
|
255
570
|
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
571
|
+
assertSessionBinding(input.nextSession);
|
|
256
572
|
await this.waitUntilReady();
|
|
257
573
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
258
|
-
throw new
|
|
574
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
|
|
259
575
|
}
|
|
260
576
|
try {
|
|
261
577
|
return await this.rotateSessionWithTransaction(input);
|
|
262
578
|
} catch (error) {
|
|
263
579
|
if (isDuplicateKeyError(error)) {
|
|
264
|
-
throw new
|
|
580
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
|
|
265
581
|
}
|
|
266
582
|
throw error;
|
|
267
583
|
}
|
|
@@ -360,10 +676,13 @@ var MongoOidcVaultStore = class {
|
|
|
360
676
|
exchangeCodes: this.exchangeCodes,
|
|
361
677
|
sessions: this.sessions,
|
|
362
678
|
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
363
|
-
rotatedSessionAliases: this.rotatedSessionAliases
|
|
679
|
+
rotatedSessionAliases: this.rotatedSessionAliases,
|
|
680
|
+
dpopProofs: this.dpopProofs,
|
|
681
|
+
dpopReplayCapacity: this.dpopReplayCapacity
|
|
364
682
|
}),
|
|
365
683
|
assertTransactionSupport(this.db)
|
|
366
684
|
]);
|
|
685
|
+
await this.dpopReplay.initialize();
|
|
367
686
|
}
|
|
368
687
|
async waitUntilReady() {
|
|
369
688
|
await this.initialization;
|
|
@@ -379,15 +698,15 @@ var MongoOidcVaultStore = class {
|
|
|
379
698
|
await session.withTransaction(async () => {
|
|
380
699
|
const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
|
|
381
700
|
if (!current || isExpired(current, this.now())) {
|
|
382
|
-
throw new
|
|
701
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
383
702
|
}
|
|
384
703
|
if (!isSameSessionDocumentGeneration(current, expectedSource)) {
|
|
385
|
-
throw new
|
|
704
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session changed before rotation could commit.");
|
|
386
705
|
}
|
|
387
706
|
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
388
707
|
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
389
708
|
if (deleteResult.deletedCount !== 1) {
|
|
390
|
-
throw new
|
|
709
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
391
710
|
}
|
|
392
711
|
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
393
712
|
});
|
|
@@ -417,8 +736,9 @@ var MongoOidcVaultStore = class {
|
|
|
417
736
|
async normalizeRotatedSession(input) {
|
|
418
737
|
const previous = await this.getSession(input.sessionId);
|
|
419
738
|
if (!previous) {
|
|
420
|
-
throw new
|
|
739
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
421
740
|
}
|
|
741
|
+
inheritSessionBinding(previous, input.nextSession);
|
|
422
742
|
return {
|
|
423
743
|
previous,
|
|
424
744
|
nextSession: {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/express-oidc-vault-mongodb-store",
|
|
3
3
|
"description": "MongoDB store provider for @web-ts-toolkit/express-oidc-vault",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-mongodb-store",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.49.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"express",
|
|
@@ -15,17 +15,21 @@
|
|
|
15
15
|
"types": "./index.d.ts",
|
|
16
16
|
"exports": {
|
|
17
17
|
".": {
|
|
18
|
-
"types":
|
|
18
|
+
"types": {
|
|
19
|
+
"import": "./index.d.mts",
|
|
20
|
+
"require": "./index.d.ts",
|
|
21
|
+
"default": "./index.d.ts"
|
|
22
|
+
},
|
|
19
23
|
"import": "./index.mjs",
|
|
20
24
|
"require": "./index.js",
|
|
21
25
|
"default": "./index.js"
|
|
22
26
|
}
|
|
23
27
|
},
|
|
24
28
|
"engines": {
|
|
25
|
-
"node": ">=22"
|
|
29
|
+
"node": ">=22.12.0"
|
|
26
30
|
},
|
|
27
31
|
"dependencies": {
|
|
28
|
-
"@web-ts-toolkit/express-oidc-vault": "0.
|
|
32
|
+
"@web-ts-toolkit/express-oidc-vault": "0.49.0",
|
|
29
33
|
"mongodb": "^6.20.0"
|
|
30
34
|
},
|
|
31
35
|
"files": [
|