@web-ts-toolkit/express-oidc-vault-mongodb-store 0.43.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -10
- package/index.d.mts +13 -6
- package/index.d.ts +13 -6
- package/index.js +428 -40
- package/index.mjs +429 -41
- package/package.json +8 -4
package/index.js
CHANGED
|
@@ -26,20 +26,96 @@ __export(index_exports, {
|
|
|
26
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
27
|
|
|
28
28
|
// src/store.ts
|
|
29
|
+
var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
|
|
30
|
+
var import_node_util = require("util");
|
|
31
|
+
|
|
32
|
+
// src/binding.ts
|
|
29
33
|
var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
|
|
34
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
|
|
36
|
+
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);
|
|
37
|
+
var hasValidSessionBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
|
|
38
|
+
var hasValidRecordBinding = (record) => hasValidSessionBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
|
|
39
|
+
var assertRecordBinding = (record) => {
|
|
40
|
+
if (!hasValidRecordBinding(record))
|
|
41
|
+
throw new TypeError("OIDC vault store record has invalid device/browser binding.");
|
|
42
|
+
};
|
|
43
|
+
var assertSessionBinding = (record) => {
|
|
44
|
+
if (!hasValidSessionBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
|
|
45
|
+
};
|
|
46
|
+
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));
|
|
47
|
+
var bindingMatchFilter = (match) => ({
|
|
48
|
+
...match.deviceBinding === null ? { deviceBinding: { $exists: false } } : {
|
|
49
|
+
"deviceBinding.type": match.deviceBinding.type,
|
|
50
|
+
"deviceBinding.jkt": match.deviceBinding.jkt,
|
|
51
|
+
$expr: {
|
|
52
|
+
$eq: [
|
|
53
|
+
{
|
|
54
|
+
$size: {
|
|
55
|
+
$objectToArray: { $cond: [{ $eq: [{ $type: "$deviceBinding" }, "object"] }, "$deviceBinding", {}] }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
2
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
browserBindingHash: match.browserBindingHash === null ? { $exists: false } : match.browserBindingHash
|
|
63
|
+
});
|
|
64
|
+
var inheritSessionBinding = (source, next) => {
|
|
65
|
+
assertSessionBinding(source);
|
|
66
|
+
assertSessionBinding(next);
|
|
67
|
+
if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
|
|
68
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
|
|
69
|
+
}
|
|
70
|
+
if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
|
|
71
|
+
};
|
|
72
|
+
var toRevocationContext = (session) => {
|
|
73
|
+
assertSessionBinding(session);
|
|
74
|
+
const provider = session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider;
|
|
75
|
+
const expiry = session.deviceBinding === void 0 && session.expiresAt === null ? void 0 : session.expiresAt;
|
|
76
|
+
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")) {
|
|
77
|
+
throw new Error("OIDC vault MongoDB lineage has malformed session/provider authority.");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
81
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
82
|
+
...provider === void 0 ? {} : {
|
|
83
|
+
provider: {
|
|
84
|
+
...provider.issuer === void 0 ? {} : { issuer: provider.issuer },
|
|
85
|
+
...provider.clientId === void 0 ? {} : { clientId: provider.clientId }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
var assertSameLineageAuthority = (source, member) => {
|
|
91
|
+
toRevocationContext(member);
|
|
92
|
+
if (source.deviceBinding?.jkt !== member.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== member.provider?.issuer || source.provider?.clientId !== member.provider?.clientId) {
|
|
93
|
+
throw new Error("OIDC vault MongoDB lineage has inconsistent device binding or provider identity.");
|
|
94
|
+
}
|
|
95
|
+
};
|
|
30
96
|
|
|
31
97
|
// src/documents.ts
|
|
98
|
+
var providerToDocument = (provider) => {
|
|
99
|
+
if (provider === null || typeof provider !== "object") return provider;
|
|
100
|
+
const prototype = Object.getPrototypeOf(provider);
|
|
101
|
+
if (prototype !== Object.prototype && prototype !== null) return provider;
|
|
102
|
+
const copy = { ...provider };
|
|
103
|
+
if (copy.issuer === void 0) delete copy.issuer;
|
|
104
|
+
if (copy.clientId === void 0) delete copy.clientId;
|
|
105
|
+
return copy;
|
|
106
|
+
};
|
|
32
107
|
var sessionToDocument = (session) => ({
|
|
33
108
|
_id: session.sessionId,
|
|
34
109
|
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
35
110
|
subject: session.subject,
|
|
36
111
|
providerSessionId: session.providerSessionId,
|
|
37
|
-
provider: session.provider,
|
|
112
|
+
...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
|
|
113
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
38
114
|
refreshToken: session.refreshToken,
|
|
39
115
|
idToken: session.idToken,
|
|
40
116
|
accessToken: session.accessToken,
|
|
41
117
|
scope: session.scope,
|
|
42
|
-
|
|
118
|
+
...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
|
|
43
119
|
createdAt: session.createdAt,
|
|
44
120
|
updatedAt: session.updatedAt,
|
|
45
121
|
user: session.user,
|
|
@@ -50,7 +126,8 @@ var documentToSession = (session) => ({
|
|
|
50
126
|
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
51
127
|
subject: session.subject,
|
|
52
128
|
providerSessionId: session.providerSessionId,
|
|
53
|
-
provider: session.provider,
|
|
129
|
+
provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
|
|
130
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
54
131
|
refreshToken: session.refreshToken,
|
|
55
132
|
idToken: session.idToken,
|
|
56
133
|
accessToken: session.accessToken,
|
|
@@ -61,22 +138,26 @@ var documentToSession = (session) => ({
|
|
|
61
138
|
user: session.user,
|
|
62
139
|
metadata: session.metadata
|
|
63
140
|
});
|
|
64
|
-
var authorizationTransactionToDocument = (record) =>
|
|
65
|
-
_id: record.state,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
...record,
|
|
72
|
-
|
|
73
|
-
|
|
141
|
+
var authorizationTransactionToDocument = (record) => {
|
|
142
|
+
const document = { _id: record.state, ...record, expiresAt: new Date(record.expiresAt) };
|
|
143
|
+
if (document.deviceBinding === void 0) delete document.deviceBinding;
|
|
144
|
+
if (document.browserBindingHash === void 0) delete document.browserBindingHash;
|
|
145
|
+
return document;
|
|
146
|
+
};
|
|
147
|
+
var exchangeCodeToDocument = (record) => {
|
|
148
|
+
const document = { _id: record.code, ...record, expiresAt: new Date(record.expiresAt) };
|
|
149
|
+
if (document.deviceBinding === void 0) delete document.deviceBinding;
|
|
150
|
+
if (document.browserBindingHash === void 0) delete document.browserBindingHash;
|
|
151
|
+
return document;
|
|
152
|
+
};
|
|
74
153
|
var authorizationDocumentToRecord = (record) => ({
|
|
75
154
|
state: record.state,
|
|
76
155
|
nonce: record.nonce,
|
|
77
156
|
pkceVerifier: record.pkceVerifier,
|
|
78
157
|
codeChallenge: record.codeChallenge,
|
|
79
158
|
returnTo: record.returnTo,
|
|
159
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
160
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
80
161
|
createdAt: record.createdAt,
|
|
81
162
|
expiresAt: record.expiresAt.getTime(),
|
|
82
163
|
metadata: record.metadata
|
|
@@ -85,10 +166,17 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
85
166
|
code: record.code,
|
|
86
167
|
sessionId: record.sessionId,
|
|
87
168
|
returnTo: record.returnTo,
|
|
169
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
170
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
88
171
|
createdAt: record.createdAt,
|
|
89
172
|
expiresAt: record.expiresAt.getTime()
|
|
90
173
|
});
|
|
91
174
|
var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
|
|
175
|
+
var validDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
176
|
+
var optionalString = (value) => value === void 0 || typeof value === "string";
|
|
177
|
+
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);
|
|
178
|
+
var validExchangeDocument = (record, code) => record._id === code && record.code === code && typeof record.sessionId === "string" && Number.isFinite(record.createdAt) && validDate(record.expiresAt) && hasValidRecordBinding(record);
|
|
179
|
+
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);
|
|
92
180
|
|
|
93
181
|
// src/options.ts
|
|
94
182
|
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
@@ -97,7 +185,9 @@ var DEFAULT_COLLECTION_NAMES = {
|
|
|
97
185
|
exchangeCodes: "oidc_vault_exchange_codes",
|
|
98
186
|
sessions: "oidc_vault_sessions",
|
|
99
187
|
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
100
|
-
rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
|
|
188
|
+
rotatedSessionAliases: "oidc_vault_rotated_session_aliases",
|
|
189
|
+
dpopProofs: "oidc_vault_dpop_proofs",
|
|
190
|
+
dpopReplayCapacity: "oidc_vault_dpop_replay_capacity"
|
|
101
191
|
};
|
|
102
192
|
var validateCollectionName = (role, name) => {
|
|
103
193
|
if (name.length === 0) {
|
|
@@ -119,7 +209,9 @@ var resolveCollectionNames = (options) => {
|
|
|
119
209
|
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
120
210
|
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
121
211
|
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
122
|
-
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
|
|
212
|
+
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases,
|
|
213
|
+
dpopProofs: options.dpopProofsCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopProofs,
|
|
214
|
+
dpopReplayCapacity: options.dpopReplayCapacityCollectionName ?? DEFAULT_COLLECTION_NAMES.dpopReplayCapacity
|
|
123
215
|
};
|
|
124
216
|
for (const [role, name] of Object.entries(collectionNames)) {
|
|
125
217
|
validateCollectionName(role, name);
|
|
@@ -161,6 +253,10 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
161
253
|
createTtlIndex(collections.sessions),
|
|
162
254
|
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
163
255
|
createTtlIndex(collections.rotatedSessionAliases),
|
|
256
|
+
createTtlIndex(collections.dpopProofs),
|
|
257
|
+
// Accounting outlives physical proof TTL deletion until a serialized
|
|
258
|
+
// bounded cleanup decrements capacity. Never TTL-delete these entries.
|
|
259
|
+
collections.dpopReplayCapacity.createIndex({ kind: 1, expiresAt: 1 }, { name: "dpop_expiry_accounting_idx" }),
|
|
164
260
|
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
165
261
|
collections.sessions.createIndex(
|
|
166
262
|
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
@@ -174,14 +270,155 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
174
270
|
]);
|
|
175
271
|
};
|
|
176
272
|
|
|
273
|
+
// src/dpop-replay.ts
|
|
274
|
+
var import_express_oidc_vault2 = require("@web-ts-toolkit/express-oidc-vault");
|
|
275
|
+
var CAPACITY_ID = "capacity";
|
|
276
|
+
var reservationId = (replayKey) => `proof:${replayKey}`;
|
|
277
|
+
var isDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
278
|
+
var MongoDpopReplayReservations = class {
|
|
279
|
+
constructor(db, proofs, capacity, maxEntries, now) {
|
|
280
|
+
this.db = db;
|
|
281
|
+
this.proofs = proofs;
|
|
282
|
+
this.capacity = capacity;
|
|
283
|
+
this.maxEntries = maxEntries;
|
|
284
|
+
this.now = now;
|
|
285
|
+
}
|
|
286
|
+
async initialize() {
|
|
287
|
+
const indexes = await this.capacity.listIndexes().toArray();
|
|
288
|
+
if (indexes.some((index) => index.expireAfterSeconds !== void 0)) {
|
|
289
|
+
throw new Error("OIDC vault MongoDB replay capacity accounting must not have TTL indexes.");
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await this.capacity.updateOne(
|
|
293
|
+
{ _id: CAPACITY_ID },
|
|
294
|
+
{
|
|
295
|
+
$setOnInsert: {
|
|
296
|
+
kind: "capacity",
|
|
297
|
+
entries: 0,
|
|
298
|
+
revision: 0,
|
|
299
|
+
maxEntries: this.maxEntries,
|
|
300
|
+
proofsCollectionName: this.proofs.collectionName
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{ upsert: true }
|
|
304
|
+
);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (!(typeof error === "object" && error !== null && "code" in error && error.code === 11e3)) throw error;
|
|
307
|
+
}
|
|
308
|
+
this.validateCapacity(await this.capacity.findOne({ _id: CAPACITY_ID }));
|
|
309
|
+
}
|
|
310
|
+
async reserve(input) {
|
|
311
|
+
if (!this.validWindow(input, this.now())) return false;
|
|
312
|
+
const session = this.db.client.startSession();
|
|
313
|
+
try {
|
|
314
|
+
const result = await session.withTransaction(
|
|
315
|
+
async () => {
|
|
316
|
+
const now = this.now();
|
|
317
|
+
if (!this.validWindow(input, now)) return false;
|
|
318
|
+
const capacity = await this.capacity.findOneAndUpdate(
|
|
319
|
+
{ _id: CAPACITY_ID },
|
|
320
|
+
{ $inc: { revision: 1 } },
|
|
321
|
+
{ session, returnDocument: "after" }
|
|
322
|
+
);
|
|
323
|
+
this.validateCapacity(capacity);
|
|
324
|
+
const proof = await this.proofs.findOne({ _id: input.replayKey }, { session });
|
|
325
|
+
if (proof && !isDate(proof.expiresAt))
|
|
326
|
+
throw new Error("OIDC vault MongoDB replay proof has malformed expiry.");
|
|
327
|
+
if (proof && proof.expiresAt.getTime() > now) return false;
|
|
328
|
+
const id = reservationId(input.replayKey);
|
|
329
|
+
const existing = await this.capacity.findOne({ _id: id }, { session });
|
|
330
|
+
if (existing) {
|
|
331
|
+
this.validateReservation(existing);
|
|
332
|
+
if (existing.expiresAt.getTime() > now) return false;
|
|
333
|
+
}
|
|
334
|
+
let removed = 0;
|
|
335
|
+
if (existing) removed += (await this.capacity.deleteOne({ _id: id }, { session })).deletedCount;
|
|
336
|
+
const expired = await this.capacity.find({ kind: "reservation", expiresAt: { $lte: new Date(now) } }, { session }).sort({ expiresAt: 1 }).limit(64).toArray();
|
|
337
|
+
for (const row of expired) this.validateReservation(row);
|
|
338
|
+
if (expired.length > 0) {
|
|
339
|
+
removed += (await this.capacity.deleteMany(
|
|
340
|
+
{ _id: { $in: expired.map((row) => row._id) }, kind: "reservation" },
|
|
341
|
+
{ session }
|
|
342
|
+
)).deletedCount;
|
|
343
|
+
await this.proofs.deleteMany(
|
|
344
|
+
{ _id: { $in: expired.map((row) => row.replayKey) }, expiresAt: { $lte: new Date(now) } },
|
|
345
|
+
{ session }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const entries = capacity.entries - removed;
|
|
349
|
+
if (!Number.isSafeInteger(entries) || entries < 0)
|
|
350
|
+
throw new Error("OIDC vault MongoDB replay capacity accounting is inconsistent.");
|
|
351
|
+
if (entries >= this.maxEntries) throw new import_express_oidc_vault2.OidcVaultDpopReplayCapacityError();
|
|
352
|
+
await this.proofs.replaceOne(
|
|
353
|
+
{ _id: input.replayKey },
|
|
354
|
+
{ expiresAt: new Date(input.expiresAt) },
|
|
355
|
+
{ upsert: true, session }
|
|
356
|
+
);
|
|
357
|
+
await this.capacity.insertOne(
|
|
358
|
+
{ _id: id, kind: "reservation", replayKey: input.replayKey, expiresAt: new Date(input.expiresAt) },
|
|
359
|
+
{ session }
|
|
360
|
+
);
|
|
361
|
+
await this.capacity.updateOne({ _id: CAPACITY_ID }, { $set: { entries: entries + 1 } }, { session });
|
|
362
|
+
return true;
|
|
363
|
+
},
|
|
364
|
+
{ readConcern: { level: "snapshot" } }
|
|
365
|
+
);
|
|
366
|
+
return result === true;
|
|
367
|
+
} finally {
|
|
368
|
+
await session.endSession();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
validWindow(input, now) {
|
|
372
|
+
if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
|
|
373
|
+
return typeof input.replayKey === "string" && Number.isSafeInteger(input.expiresAt) && input.expiresAt > now && input.expiresAt - now <= 36e4;
|
|
374
|
+
}
|
|
375
|
+
validateCapacity(row) {
|
|
376
|
+
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)) {
|
|
377
|
+
throw new Error("OIDC vault MongoDB replay capacity configuration/accounting is inconsistent.");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
validateReservation(row) {
|
|
381
|
+
if (row.kind !== "reservation" || typeof row.replayKey !== "string" || row._id !== reservationId(row.replayKey) || !isDate(row.expiresAt)) {
|
|
382
|
+
throw new Error("OIDC vault MongoDB replay expiry accounting is malformed.");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
177
387
|
// src/store.ts
|
|
178
|
-
var
|
|
179
|
-
|
|
180
|
-
|
|
388
|
+
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
389
|
+
if (input === null || typeof input !== "object") return input;
|
|
390
|
+
const prototype = Object.getPrototypeOf(input);
|
|
391
|
+
if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
|
|
392
|
+
if (seen.has(input)) return seen.get(input);
|
|
393
|
+
const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
|
|
394
|
+
seen.set(input, copy);
|
|
395
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
396
|
+
if (Object.prototype.propertyIsEnumerable.call(input, key)) {
|
|
397
|
+
Object.defineProperty(copy, key, {
|
|
398
|
+
value: snapshotInput(Reflect.get(input, key), seen),
|
|
399
|
+
enumerable: true,
|
|
400
|
+
configurable: true,
|
|
401
|
+
writable: true
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return copy;
|
|
406
|
+
};
|
|
407
|
+
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
|
|
408
|
+
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
409
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
181
410
|
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
411
|
+
var isSameSessionDocumentGeneration = (current, expected) => current._id === expected._id && (current.logicalSessionId ?? current._id) === (expected.logicalSessionId ?? expected._id) && current.subject === expected.subject && current.providerSessionId === expected.providerSessionId && (0, import_node_util.isDeepStrictEqual)(
|
|
412
|
+
current.deviceBinding === void 0 && current.provider === null ? void 0 : current.provider,
|
|
413
|
+
expected.provider
|
|
414
|
+
) && (0, import_node_util.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 && (0, import_node_util.isDeepStrictEqual)(current.user, expected.user) && (0, import_node_util.isDeepStrictEqual)(current.metadata, expected.metadata);
|
|
182
415
|
var MongoOidcVaultStore = class {
|
|
183
416
|
constructor(options) {
|
|
184
417
|
const collectionNames = resolveCollectionNames(options);
|
|
418
|
+
const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
|
|
419
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
|
|
420
|
+
throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
|
|
421
|
+
}
|
|
185
422
|
this.db = options.db;
|
|
186
423
|
this.authorizationTransactions = options.db.collection(
|
|
187
424
|
collectionNames.authorizationTransactions
|
|
@@ -194,11 +431,20 @@ var MongoOidcVaultStore = class {
|
|
|
194
431
|
this.rotatedSessionAliases = options.db.collection(
|
|
195
432
|
collectionNames.rotatedSessionAliases
|
|
196
433
|
);
|
|
434
|
+
this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
|
|
435
|
+
this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
|
|
197
436
|
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
198
437
|
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
199
438
|
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
200
439
|
}
|
|
201
440
|
this.now = options.now ?? (() => Date.now());
|
|
441
|
+
this.dpopReplay = new MongoDpopReplayReservations(
|
|
442
|
+
this.db,
|
|
443
|
+
this.dpopProofs,
|
|
444
|
+
this.dpopReplayCapacity,
|
|
445
|
+
maxEntries,
|
|
446
|
+
this.now
|
|
447
|
+
);
|
|
202
448
|
this.initialization = this.initialize().then(void 0, (error) => {
|
|
203
449
|
this.initializationError = error;
|
|
204
450
|
});
|
|
@@ -207,6 +453,8 @@ var MongoOidcVaultStore = class {
|
|
|
207
453
|
await this.waitUntilReady();
|
|
208
454
|
}
|
|
209
455
|
async createAuthorizationTransaction(input) {
|
|
456
|
+
input = snapshotInput({ ...input });
|
|
457
|
+
assertRecordBinding(input);
|
|
210
458
|
await this.waitUntilReady();
|
|
211
459
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
212
460
|
upsert: true
|
|
@@ -214,25 +462,70 @@ var MongoOidcVaultStore = class {
|
|
|
214
462
|
}
|
|
215
463
|
async consumeAuthorizationTransaction(state) {
|
|
216
464
|
await this.waitUntilReady();
|
|
217
|
-
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
218
|
-
|
|
465
|
+
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
466
|
+
_id: state,
|
|
467
|
+
deviceBinding: { $exists: false },
|
|
468
|
+
browserBindingHash: { $exists: false }
|
|
469
|
+
});
|
|
470
|
+
if (!record || !validAuthorizationDocument(record, state) || isExpired(record, this.now())) {
|
|
219
471
|
return null;
|
|
220
472
|
}
|
|
221
473
|
return authorizationDocumentToRecord(record);
|
|
222
474
|
}
|
|
475
|
+
async getAuthorizationTransaction(state) {
|
|
476
|
+
await this.waitUntilReady();
|
|
477
|
+
const record = await this.authorizationTransactions.findOne({ _id: state });
|
|
478
|
+
return record && validAuthorizationDocument(record, state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
|
|
479
|
+
}
|
|
480
|
+
async consumeAuthorizationTransactionIfMatches(input) {
|
|
481
|
+
input = snapshotInput({ ...input });
|
|
482
|
+
if (!isBindingMatch(input.match)) return null;
|
|
483
|
+
await this.waitUntilReady();
|
|
484
|
+
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
485
|
+
_id: input.state,
|
|
486
|
+
expiresAt: { $gt: new Date(this.now()) },
|
|
487
|
+
...bindingMatchFilter(input.match)
|
|
488
|
+
});
|
|
489
|
+
return record && validAuthorizationDocument(record, input.state) && !isExpired(record, this.now()) ? authorizationDocumentToRecord(record) : null;
|
|
490
|
+
}
|
|
223
491
|
async createExchangeCode(input) {
|
|
492
|
+
input = snapshotInput({ ...input });
|
|
493
|
+
assertRecordBinding(input);
|
|
224
494
|
await this.waitUntilReady();
|
|
225
495
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
226
496
|
}
|
|
227
497
|
async consumeExchangeCode(code) {
|
|
228
498
|
await this.waitUntilReady();
|
|
229
|
-
const record = await this.exchangeCodes.findOneAndDelete({
|
|
230
|
-
|
|
499
|
+
const record = await this.exchangeCodes.findOneAndDelete({
|
|
500
|
+
_id: code,
|
|
501
|
+
deviceBinding: { $exists: false },
|
|
502
|
+
browserBindingHash: { $exists: false }
|
|
503
|
+
});
|
|
504
|
+
if (!record || !validExchangeDocument(record, code) || isExpired(record, this.now())) {
|
|
231
505
|
return null;
|
|
232
506
|
}
|
|
233
507
|
return exchangeDocumentToRecord(record);
|
|
234
508
|
}
|
|
509
|
+
async getExchangeCode(code) {
|
|
510
|
+
await this.waitUntilReady();
|
|
511
|
+
const record = await this.exchangeCodes.findOne({ _id: code });
|
|
512
|
+
return record && validExchangeDocument(record, code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
|
|
513
|
+
}
|
|
514
|
+
async consumeExchangeCodeIfMatches(input) {
|
|
515
|
+
input = snapshotInput({ ...input });
|
|
516
|
+
if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
|
|
517
|
+
await this.waitUntilReady();
|
|
518
|
+
const record = await this.exchangeCodes.findOneAndDelete({
|
|
519
|
+
_id: input.code,
|
|
520
|
+
sessionId: input.expectedSessionId,
|
|
521
|
+
expiresAt: { $gt: new Date(this.now()) },
|
|
522
|
+
...bindingMatchFilter(input.match)
|
|
523
|
+
});
|
|
524
|
+
return record && validExchangeDocument(record, input.code) && !isExpired(record, this.now()) ? exchangeDocumentToRecord(record) : null;
|
|
525
|
+
}
|
|
235
526
|
async createSession(input) {
|
|
527
|
+
input = snapshotInput({ ...input });
|
|
528
|
+
assertSessionBinding(input);
|
|
236
529
|
await this.waitUntilReady();
|
|
237
530
|
const timestamp = this.now();
|
|
238
531
|
const session = {
|
|
@@ -247,21 +540,69 @@ var MongoOidcVaultStore = class {
|
|
|
247
540
|
async getSession(sessionId) {
|
|
248
541
|
await this.waitUntilReady();
|
|
249
542
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
250
|
-
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
543
|
+
if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
251
544
|
return null;
|
|
252
545
|
}
|
|
253
546
|
return documentToSession(session);
|
|
254
547
|
}
|
|
548
|
+
async getSessionRevocationContext(sessionId) {
|
|
549
|
+
await this.waitUntilReady();
|
|
550
|
+
const transaction = this.db.client.startSession();
|
|
551
|
+
const projection = { _id: 1, logicalSessionId: 1, subject: 1, provider: 1, deviceBinding: 1, expiresAt: 1 };
|
|
552
|
+
try {
|
|
553
|
+
return await transaction.withTransaction(
|
|
554
|
+
async () => {
|
|
555
|
+
const now = this.now();
|
|
556
|
+
const live = await this.sessions.findOne({ _id: sessionId }, { session: transaction, projection });
|
|
557
|
+
let logicalSessionId;
|
|
558
|
+
if (live) {
|
|
559
|
+
toRevocationContext(live);
|
|
560
|
+
if (!isExpired(live, now)) logicalSessionId = live.logicalSessionId ?? live._id;
|
|
561
|
+
}
|
|
562
|
+
if (logicalSessionId === void 0) {
|
|
563
|
+
const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId }, { session: transaction });
|
|
564
|
+
if (!alias || isExpired(alias, now)) return null;
|
|
565
|
+
if (typeof alias.logicalSessionId !== "string" || !(alias.expiresAt instanceof Date) || !Number.isFinite(alias.expiresAt.getTime())) {
|
|
566
|
+
throw new Error("OIDC vault MongoDB store has malformed revocation alias.");
|
|
567
|
+
}
|
|
568
|
+
logicalSessionId = alias.logicalSessionId;
|
|
569
|
+
}
|
|
570
|
+
const members = await this.sessions.find(
|
|
571
|
+
{ $or: [{ logicalSessionId }, { logicalSessionId: { $exists: false }, _id: logicalSessionId }] },
|
|
572
|
+
{ session: transaction, projection }
|
|
573
|
+
).toArray();
|
|
574
|
+
let source;
|
|
575
|
+
for (const member of members) {
|
|
576
|
+
toRevocationContext(member);
|
|
577
|
+
if (isExpired(member, now)) continue;
|
|
578
|
+
if (source) assertSameLineageAuthority(source, member);
|
|
579
|
+
else source = member;
|
|
580
|
+
}
|
|
581
|
+
return source ? toRevocationContext(source) : null;
|
|
582
|
+
},
|
|
583
|
+
{ readConcern: { level: "snapshot" } }
|
|
584
|
+
) ?? null;
|
|
585
|
+
} finally {
|
|
586
|
+
await transaction.endSession();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async reserveDpopProof(input) {
|
|
590
|
+
input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
|
|
591
|
+
await this.waitUntilReady();
|
|
592
|
+
return this.dpopReplay.reserve(input);
|
|
593
|
+
}
|
|
255
594
|
async rotateSession(input) {
|
|
595
|
+
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
596
|
+
assertSessionBinding(input.nextSession);
|
|
256
597
|
await this.waitUntilReady();
|
|
257
598
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
258
|
-
throw new
|
|
599
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
259
600
|
}
|
|
260
601
|
try {
|
|
261
602
|
return await this.rotateSessionWithTransaction(input);
|
|
262
603
|
} catch (error) {
|
|
263
604
|
if (isDuplicateKeyError(error)) {
|
|
264
|
-
throw new
|
|
605
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
265
606
|
}
|
|
266
607
|
throw error;
|
|
267
608
|
}
|
|
@@ -273,7 +614,7 @@ var MongoOidcVaultStore = class {
|
|
|
273
614
|
const logicalSessionId = session.logicalSessionId ?? session._id;
|
|
274
615
|
const result = await this.sessions.deleteOne({ _id: sessionId });
|
|
275
616
|
if (result.deletedCount === 1) {
|
|
276
|
-
await this.
|
|
617
|
+
await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
|
|
277
618
|
return;
|
|
278
619
|
}
|
|
279
620
|
const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
@@ -288,13 +629,14 @@ var MongoOidcVaultStore = class {
|
|
|
288
629
|
}
|
|
289
630
|
}
|
|
290
631
|
async deleteSessionsByLogicalSessionId(input) {
|
|
291
|
-
await this.waitUntilReady();
|
|
292
632
|
const resolved = toLogicalSessionDeleteInput(input);
|
|
633
|
+
await this.waitUntilReady();
|
|
293
634
|
const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
|
|
294
|
-
await this.
|
|
635
|
+
await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
|
|
295
636
|
return result.deletedCount;
|
|
296
637
|
}
|
|
297
638
|
async consumeBackchannelLogoutTokenJti(input) {
|
|
639
|
+
input = { ...input };
|
|
298
640
|
await this.waitUntilReady();
|
|
299
641
|
const now = this.now();
|
|
300
642
|
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
@@ -327,8 +669,8 @@ var MongoOidcVaultStore = class {
|
|
|
327
669
|
}
|
|
328
670
|
}
|
|
329
671
|
async deleteSessionsBySubject(input) {
|
|
330
|
-
await this.waitUntilReady();
|
|
331
672
|
const resolved = toSubjectDeleteInput(input);
|
|
673
|
+
await this.waitUntilReady();
|
|
332
674
|
const filter = { subject: resolved.subject };
|
|
333
675
|
if (resolved.issuer !== void 0) {
|
|
334
676
|
filter["provider.issuer"] = resolved.issuer;
|
|
@@ -340,8 +682,8 @@ var MongoOidcVaultStore = class {
|
|
|
340
682
|
return result.deletedCount;
|
|
341
683
|
}
|
|
342
684
|
async deleteSessionsByProviderSessionId(input) {
|
|
343
|
-
await this.waitUntilReady();
|
|
344
685
|
const resolved = toProviderSessionDeleteInput(input);
|
|
686
|
+
await this.waitUntilReady();
|
|
345
687
|
const filter = { providerSessionId: resolved.providerSessionId };
|
|
346
688
|
if (resolved.issuer !== void 0) {
|
|
347
689
|
filter["provider.issuer"] = resolved.issuer;
|
|
@@ -359,10 +701,13 @@ var MongoOidcVaultStore = class {
|
|
|
359
701
|
exchangeCodes: this.exchangeCodes,
|
|
360
702
|
sessions: this.sessions,
|
|
361
703
|
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
362
|
-
rotatedSessionAliases: this.rotatedSessionAliases
|
|
704
|
+
rotatedSessionAliases: this.rotatedSessionAliases,
|
|
705
|
+
dpopProofs: this.dpopProofs,
|
|
706
|
+
dpopReplayCapacity: this.dpopReplayCapacity
|
|
363
707
|
}),
|
|
364
708
|
assertTransactionSupport(this.db)
|
|
365
709
|
]);
|
|
710
|
+
await this.dpopReplay.initialize();
|
|
366
711
|
}
|
|
367
712
|
async waitUntilReady() {
|
|
368
713
|
await this.initialization;
|
|
@@ -371,14 +716,22 @@ var MongoOidcVaultStore = class {
|
|
|
371
716
|
}
|
|
372
717
|
}
|
|
373
718
|
async rotateSessionWithTransaction(input) {
|
|
719
|
+
const { previous, nextSession } = await this.normalizeRotatedSession(input);
|
|
720
|
+
const expectedSource = sessionToDocument(previous);
|
|
374
721
|
const session = this.db.client.startSession();
|
|
375
|
-
const nextSession = await this.normalizeRotatedSession(input);
|
|
376
722
|
try {
|
|
377
723
|
await session.withTransaction(async () => {
|
|
724
|
+
const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
|
|
725
|
+
if (!current || isExpired(current, this.now())) {
|
|
726
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
727
|
+
}
|
|
728
|
+
if (!isSameSessionDocumentGeneration(current, expectedSource)) {
|
|
729
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
|
|
730
|
+
}
|
|
378
731
|
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
379
732
|
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
380
733
|
if (deleteResult.deletedCount !== 1) {
|
|
381
|
-
throw new
|
|
734
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
382
735
|
}
|
|
383
736
|
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
384
737
|
});
|
|
@@ -397,7 +750,10 @@ var MongoOidcVaultStore = class {
|
|
|
397
750
|
$set: {
|
|
398
751
|
logicalSessionId,
|
|
399
752
|
expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
|
|
400
|
-
}
|
|
753
|
+
},
|
|
754
|
+
// A no-op alias update would not conflict with an older cleanup
|
|
755
|
+
// snapshot. Every rotation must write, even with identical values.
|
|
756
|
+
$inc: { revision: 1 }
|
|
401
757
|
},
|
|
402
758
|
{ upsert: true, session }
|
|
403
759
|
);
|
|
@@ -405,11 +761,15 @@ var MongoOidcVaultStore = class {
|
|
|
405
761
|
async normalizeRotatedSession(input) {
|
|
406
762
|
const previous = await this.getSession(input.sessionId);
|
|
407
763
|
if (!previous) {
|
|
408
|
-
throw new
|
|
764
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
409
765
|
}
|
|
766
|
+
inheritSessionBinding(previous, input.nextSession);
|
|
410
767
|
return {
|
|
411
|
-
|
|
412
|
-
|
|
768
|
+
previous,
|
|
769
|
+
nextSession: {
|
|
770
|
+
...input.nextSession,
|
|
771
|
+
logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
|
|
772
|
+
}
|
|
413
773
|
};
|
|
414
774
|
}
|
|
415
775
|
async deleteSessionsAndAliasesByFilter(filter) {
|
|
@@ -425,7 +785,7 @@ var MongoOidcVaultStore = class {
|
|
|
425
785
|
}
|
|
426
786
|
const result = await this.sessions.deleteMany(filter);
|
|
427
787
|
deletedCount += result.deletedCount;
|
|
428
|
-
await this.
|
|
788
|
+
await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
|
|
429
789
|
}
|
|
430
790
|
}
|
|
431
791
|
getRotatedSessionAliasExpiresAt(nextSession, now) {
|
|
@@ -433,8 +793,36 @@ var MongoOidcVaultStore = class {
|
|
|
433
793
|
typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
|
|
434
794
|
);
|
|
435
795
|
}
|
|
436
|
-
async
|
|
437
|
-
|
|
796
|
+
async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
|
|
797
|
+
const session = this.db.client.startSession();
|
|
798
|
+
try {
|
|
799
|
+
await session.withTransaction(
|
|
800
|
+
async () => {
|
|
801
|
+
const live = await this.sessions.find(
|
|
802
|
+
{
|
|
803
|
+
$and: [
|
|
804
|
+
{
|
|
805
|
+
$or: [
|
|
806
|
+
{ logicalSessionId: { $in: logicalSessionIds } },
|
|
807
|
+
{ logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
|
|
808
|
+
]
|
|
809
|
+
},
|
|
810
|
+
{ $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
|
|
811
|
+
]
|
|
812
|
+
},
|
|
813
|
+
{ session, projection: { _id: 1, logicalSessionId: 1 } }
|
|
814
|
+
).toArray();
|
|
815
|
+
const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
|
|
816
|
+
const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
|
|
817
|
+
if (inactiveIds.length > 0) {
|
|
818
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{ readConcern: { level: "snapshot" } }
|
|
822
|
+
);
|
|
823
|
+
} finally {
|
|
824
|
+
await session.endSession();
|
|
825
|
+
}
|
|
438
826
|
}
|
|
439
827
|
async isExpiredAndCleanup(collection, record) {
|
|
440
828
|
if (!isExpired(record, this.now())) {
|