@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.js
CHANGED
|
@@ -26,21 +26,96 @@ __export(index_exports, {
|
|
|
26
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
27
|
|
|
28
28
|
// src/store.ts
|
|
29
|
-
var
|
|
29
|
+
var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
|
|
30
30
|
var import_node_util = require("util");
|
|
31
31
|
|
|
32
|
+
// src/binding.ts
|
|
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
|
+
};
|
|
96
|
+
|
|
32
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
|
+
};
|
|
33
107
|
var sessionToDocument = (session) => ({
|
|
34
108
|
_id: session.sessionId,
|
|
35
109
|
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
36
110
|
subject: session.subject,
|
|
37
111
|
providerSessionId: session.providerSessionId,
|
|
38
|
-
provider: session.provider,
|
|
112
|
+
...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
|
|
113
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
39
114
|
refreshToken: session.refreshToken,
|
|
40
115
|
idToken: session.idToken,
|
|
41
116
|
accessToken: session.accessToken,
|
|
42
117
|
scope: session.scope,
|
|
43
|
-
|
|
118
|
+
...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
|
|
44
119
|
createdAt: session.createdAt,
|
|
45
120
|
updatedAt: session.updatedAt,
|
|
46
121
|
user: session.user,
|
|
@@ -51,7 +126,8 @@ var documentToSession = (session) => ({
|
|
|
51
126
|
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
52
127
|
subject: session.subject,
|
|
53
128
|
providerSessionId: session.providerSessionId,
|
|
54
|
-
provider: session.provider,
|
|
129
|
+
provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
|
|
130
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
55
131
|
refreshToken: session.refreshToken,
|
|
56
132
|
idToken: session.idToken,
|
|
57
133
|
accessToken: session.accessToken,
|
|
@@ -62,22 +138,26 @@ var documentToSession = (session) => ({
|
|
|
62
138
|
user: session.user,
|
|
63
139
|
metadata: session.metadata
|
|
64
140
|
});
|
|
65
|
-
var authorizationTransactionToDocument = (record) =>
|
|
66
|
-
_id: record.state,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...record,
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
};
|
|
75
153
|
var authorizationDocumentToRecord = (record) => ({
|
|
76
154
|
state: record.state,
|
|
77
155
|
nonce: record.nonce,
|
|
78
156
|
pkceVerifier: record.pkceVerifier,
|
|
79
157
|
codeChallenge: record.codeChallenge,
|
|
80
158
|
returnTo: record.returnTo,
|
|
159
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
160
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
81
161
|
createdAt: record.createdAt,
|
|
82
162
|
expiresAt: record.expiresAt.getTime(),
|
|
83
163
|
metadata: record.metadata
|
|
@@ -86,10 +166,17 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
86
166
|
code: record.code,
|
|
87
167
|
sessionId: record.sessionId,
|
|
88
168
|
returnTo: record.returnTo,
|
|
169
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
170
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
89
171
|
createdAt: record.createdAt,
|
|
90
172
|
expiresAt: record.expiresAt.getTime()
|
|
91
173
|
});
|
|
92
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);
|
|
93
180
|
|
|
94
181
|
// src/options.ts
|
|
95
182
|
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
@@ -98,7 +185,9 @@ var DEFAULT_COLLECTION_NAMES = {
|
|
|
98
185
|
exchangeCodes: "oidc_vault_exchange_codes",
|
|
99
186
|
sessions: "oidc_vault_sessions",
|
|
100
187
|
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
101
|
-
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"
|
|
102
191
|
};
|
|
103
192
|
var validateCollectionName = (role, name) => {
|
|
104
193
|
if (name.length === 0) {
|
|
@@ -120,7 +209,9 @@ var resolveCollectionNames = (options) => {
|
|
|
120
209
|
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
121
210
|
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
122
211
|
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
123
|
-
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
|
|
124
215
|
};
|
|
125
216
|
for (const [role, name] of Object.entries(collectionNames)) {
|
|
126
217
|
validateCollectionName(role, name);
|
|
@@ -162,6 +253,10 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
162
253
|
createTtlIndex(collections.sessions),
|
|
163
254
|
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
164
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" }),
|
|
165
260
|
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
166
261
|
collections.sessions.createIndex(
|
|
167
262
|
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
@@ -175,6 +270,120 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
175
270
|
]);
|
|
176
271
|
};
|
|
177
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
|
+
|
|
178
387
|
// src/store.ts
|
|
179
388
|
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
180
389
|
if (input === null || typeof input !== "object") return input;
|
|
@@ -199,10 +408,17 @@ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: inp
|
|
|
199
408
|
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
200
409
|
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
201
410
|
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
202
|
-
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)(
|
|
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);
|
|
203
415
|
var MongoOidcVaultStore = class {
|
|
204
416
|
constructor(options) {
|
|
205
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
|
+
}
|
|
206
422
|
this.db = options.db;
|
|
207
423
|
this.authorizationTransactions = options.db.collection(
|
|
208
424
|
collectionNames.authorizationTransactions
|
|
@@ -215,11 +431,20 @@ var MongoOidcVaultStore = class {
|
|
|
215
431
|
this.rotatedSessionAliases = options.db.collection(
|
|
216
432
|
collectionNames.rotatedSessionAliases
|
|
217
433
|
);
|
|
434
|
+
this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
|
|
435
|
+
this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
|
|
218
436
|
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
219
437
|
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
220
438
|
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
221
439
|
}
|
|
222
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
|
+
);
|
|
223
448
|
this.initialization = this.initialize().then(void 0, (error) => {
|
|
224
449
|
this.initializationError = error;
|
|
225
450
|
});
|
|
@@ -229,6 +454,7 @@ var MongoOidcVaultStore = class {
|
|
|
229
454
|
}
|
|
230
455
|
async createAuthorizationTransaction(input) {
|
|
231
456
|
input = snapshotInput({ ...input });
|
|
457
|
+
assertRecordBinding(input);
|
|
232
458
|
await this.waitUntilReady();
|
|
233
459
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
234
460
|
upsert: true
|
|
@@ -236,27 +462,70 @@ var MongoOidcVaultStore = class {
|
|
|
236
462
|
}
|
|
237
463
|
async consumeAuthorizationTransaction(state) {
|
|
238
464
|
await this.waitUntilReady();
|
|
239
|
-
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
240
|
-
|
|
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())) {
|
|
241
471
|
return null;
|
|
242
472
|
}
|
|
243
473
|
return authorizationDocumentToRecord(record);
|
|
244
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
|
+
}
|
|
245
491
|
async createExchangeCode(input) {
|
|
246
|
-
input = { ...input };
|
|
492
|
+
input = snapshotInput({ ...input });
|
|
493
|
+
assertRecordBinding(input);
|
|
247
494
|
await this.waitUntilReady();
|
|
248
495
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
249
496
|
}
|
|
250
497
|
async consumeExchangeCode(code) {
|
|
251
498
|
await this.waitUntilReady();
|
|
252
|
-
const record = await this.exchangeCodes.findOneAndDelete({
|
|
253
|
-
|
|
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())) {
|
|
254
505
|
return null;
|
|
255
506
|
}
|
|
256
507
|
return exchangeDocumentToRecord(record);
|
|
257
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
|
+
}
|
|
258
526
|
async createSession(input) {
|
|
259
527
|
input = snapshotInput({ ...input });
|
|
528
|
+
assertSessionBinding(input);
|
|
260
529
|
await this.waitUntilReady();
|
|
261
530
|
const timestamp = this.now();
|
|
262
531
|
const session = {
|
|
@@ -271,22 +540,69 @@ var MongoOidcVaultStore = class {
|
|
|
271
540
|
async getSession(sessionId) {
|
|
272
541
|
await this.waitUntilReady();
|
|
273
542
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
274
|
-
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
543
|
+
if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
275
544
|
return null;
|
|
276
545
|
}
|
|
277
546
|
return documentToSession(session);
|
|
278
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
|
+
}
|
|
279
594
|
async rotateSession(input) {
|
|
280
595
|
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
596
|
+
assertSessionBinding(input.nextSession);
|
|
281
597
|
await this.waitUntilReady();
|
|
282
598
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
283
|
-
throw new
|
|
599
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
284
600
|
}
|
|
285
601
|
try {
|
|
286
602
|
return await this.rotateSessionWithTransaction(input);
|
|
287
603
|
} catch (error) {
|
|
288
604
|
if (isDuplicateKeyError(error)) {
|
|
289
|
-
throw new
|
|
605
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
290
606
|
}
|
|
291
607
|
throw error;
|
|
292
608
|
}
|
|
@@ -385,10 +701,13 @@ var MongoOidcVaultStore = class {
|
|
|
385
701
|
exchangeCodes: this.exchangeCodes,
|
|
386
702
|
sessions: this.sessions,
|
|
387
703
|
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
388
|
-
rotatedSessionAliases: this.rotatedSessionAliases
|
|
704
|
+
rotatedSessionAliases: this.rotatedSessionAliases,
|
|
705
|
+
dpopProofs: this.dpopProofs,
|
|
706
|
+
dpopReplayCapacity: this.dpopReplayCapacity
|
|
389
707
|
}),
|
|
390
708
|
assertTransactionSupport(this.db)
|
|
391
709
|
]);
|
|
710
|
+
await this.dpopReplay.initialize();
|
|
392
711
|
}
|
|
393
712
|
async waitUntilReady() {
|
|
394
713
|
await this.initialization;
|
|
@@ -404,15 +723,15 @@ var MongoOidcVaultStore = class {
|
|
|
404
723
|
await session.withTransaction(async () => {
|
|
405
724
|
const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
|
|
406
725
|
if (!current || isExpired(current, this.now())) {
|
|
407
|
-
throw new
|
|
726
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
408
727
|
}
|
|
409
728
|
if (!isSameSessionDocumentGeneration(current, expectedSource)) {
|
|
410
|
-
throw new
|
|
729
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
|
|
411
730
|
}
|
|
412
731
|
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
413
732
|
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
414
733
|
if (deleteResult.deletedCount !== 1) {
|
|
415
|
-
throw new
|
|
734
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
416
735
|
}
|
|
417
736
|
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
418
737
|
});
|
|
@@ -442,8 +761,9 @@ var MongoOidcVaultStore = class {
|
|
|
442
761
|
async normalizeRotatedSession(input) {
|
|
443
762
|
const previous = await this.getSession(input.sessionId);
|
|
444
763
|
if (!previous) {
|
|
445
|
-
throw new
|
|
764
|
+
throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
446
765
|
}
|
|
766
|
+
inheritSessionBinding(previous, input.nextSession);
|
|
447
767
|
return {
|
|
448
768
|
previous,
|
|
449
769
|
nextSession: {
|