@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.mjs
CHANGED
|
@@ -1,20 +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
|
+
import { isDeepStrictEqual } from "util";
|
|
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
|
+
};
|
|
5
71
|
|
|
6
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
|
+
};
|
|
7
82
|
var sessionToDocument = (session) => ({
|
|
8
83
|
_id: session.sessionId,
|
|
9
84
|
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
10
85
|
subject: session.subject,
|
|
11
86
|
providerSessionId: session.providerSessionId,
|
|
12
|
-
provider: session.provider,
|
|
87
|
+
...session.provider === void 0 ? {} : { provider: providerToDocument(session.provider) },
|
|
88
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
13
89
|
refreshToken: session.refreshToken,
|
|
14
90
|
idToken: session.idToken,
|
|
15
91
|
accessToken: session.accessToken,
|
|
16
92
|
scope: session.scope,
|
|
17
|
-
|
|
93
|
+
...session.expiresAt === void 0 ? {} : { expiresAt: new Date(session.expiresAt) },
|
|
18
94
|
createdAt: session.createdAt,
|
|
19
95
|
updatedAt: session.updatedAt,
|
|
20
96
|
user: session.user,
|
|
@@ -25,7 +101,8 @@ var documentToSession = (session) => ({
|
|
|
25
101
|
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
26
102
|
subject: session.subject,
|
|
27
103
|
providerSessionId: session.providerSessionId,
|
|
28
|
-
provider: session.provider,
|
|
104
|
+
provider: session.deviceBinding === void 0 && session.provider === null ? void 0 : session.provider,
|
|
105
|
+
...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } },
|
|
29
106
|
refreshToken: session.refreshToken,
|
|
30
107
|
idToken: session.idToken,
|
|
31
108
|
accessToken: session.accessToken,
|
|
@@ -36,22 +113,26 @@ var documentToSession = (session) => ({
|
|
|
36
113
|
user: session.user,
|
|
37
114
|
metadata: session.metadata
|
|
38
115
|
});
|
|
39
|
-
var authorizationTransactionToDocument = (record) =>
|
|
40
|
-
_id: record.state,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
...record,
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
};
|
|
49
128
|
var authorizationDocumentToRecord = (record) => ({
|
|
50
129
|
state: record.state,
|
|
51
130
|
nonce: record.nonce,
|
|
52
131
|
pkceVerifier: record.pkceVerifier,
|
|
53
132
|
codeChallenge: record.codeChallenge,
|
|
54
133
|
returnTo: record.returnTo,
|
|
134
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
135
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
55
136
|
createdAt: record.createdAt,
|
|
56
137
|
expiresAt: record.expiresAt.getTime(),
|
|
57
138
|
metadata: record.metadata
|
|
@@ -60,10 +141,17 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
60
141
|
code: record.code,
|
|
61
142
|
sessionId: record.sessionId,
|
|
62
143
|
returnTo: record.returnTo,
|
|
144
|
+
...record.deviceBinding === void 0 ? {} : { deviceBinding: { ...record.deviceBinding } },
|
|
145
|
+
...record.browserBindingHash === void 0 ? {} : { browserBindingHash: record.browserBindingHash },
|
|
63
146
|
createdAt: record.createdAt,
|
|
64
147
|
expiresAt: record.expiresAt.getTime()
|
|
65
148
|
});
|
|
66
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);
|
|
67
155
|
|
|
68
156
|
// src/options.ts
|
|
69
157
|
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
@@ -72,7 +160,9 @@ var DEFAULT_COLLECTION_NAMES = {
|
|
|
72
160
|
exchangeCodes: "oidc_vault_exchange_codes",
|
|
73
161
|
sessions: "oidc_vault_sessions",
|
|
74
162
|
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
75
|
-
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"
|
|
76
166
|
};
|
|
77
167
|
var validateCollectionName = (role, name) => {
|
|
78
168
|
if (name.length === 0) {
|
|
@@ -94,7 +184,9 @@ var resolveCollectionNames = (options) => {
|
|
|
94
184
|
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
95
185
|
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
96
186
|
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
97
|
-
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
|
|
98
190
|
};
|
|
99
191
|
for (const [role, name] of Object.entries(collectionNames)) {
|
|
100
192
|
validateCollectionName(role, name);
|
|
@@ -136,6 +228,10 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
136
228
|
createTtlIndex(collections.sessions),
|
|
137
229
|
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
138
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" }),
|
|
139
235
|
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
140
236
|
collections.sessions.createIndex(
|
|
141
237
|
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
@@ -149,14 +245,155 @@ var ensureStoreIndexes = async (collections) => {
|
|
|
149
245
|
]);
|
|
150
246
|
};
|
|
151
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
|
+
|
|
152
362
|
// src/store.ts
|
|
153
|
-
var
|
|
154
|
-
|
|
155
|
-
|
|
363
|
+
var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
|
|
364
|
+
if (input === null || typeof input !== "object") return input;
|
|
365
|
+
const prototype = Object.getPrototypeOf(input);
|
|
366
|
+
if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
|
|
367
|
+
if (seen.has(input)) return seen.get(input);
|
|
368
|
+
const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
|
|
369
|
+
seen.set(input, copy);
|
|
370
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
371
|
+
if (Object.prototype.propertyIsEnumerable.call(input, key)) {
|
|
372
|
+
Object.defineProperty(copy, key, {
|
|
373
|
+
value: snapshotInput(Reflect.get(input, key), seen),
|
|
374
|
+
enumerable: true,
|
|
375
|
+
configurable: true,
|
|
376
|
+
writable: true
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return copy;
|
|
381
|
+
};
|
|
382
|
+
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
|
|
383
|
+
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
|
|
384
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
|
|
156
385
|
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
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);
|
|
157
390
|
var MongoOidcVaultStore = class {
|
|
158
391
|
constructor(options) {
|
|
159
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
|
+
}
|
|
160
397
|
this.db = options.db;
|
|
161
398
|
this.authorizationTransactions = options.db.collection(
|
|
162
399
|
collectionNames.authorizationTransactions
|
|
@@ -169,11 +406,20 @@ var MongoOidcVaultStore = class {
|
|
|
169
406
|
this.rotatedSessionAliases = options.db.collection(
|
|
170
407
|
collectionNames.rotatedSessionAliases
|
|
171
408
|
);
|
|
409
|
+
this.dpopProofs = options.db.collection(collectionNames.dpopProofs);
|
|
410
|
+
this.dpopReplayCapacity = options.db.collection(collectionNames.dpopReplayCapacity);
|
|
172
411
|
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
173
412
|
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
174
413
|
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
175
414
|
}
|
|
176
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
|
+
);
|
|
177
423
|
this.initialization = this.initialize().then(void 0, (error) => {
|
|
178
424
|
this.initializationError = error;
|
|
179
425
|
});
|
|
@@ -182,6 +428,8 @@ var MongoOidcVaultStore = class {
|
|
|
182
428
|
await this.waitUntilReady();
|
|
183
429
|
}
|
|
184
430
|
async createAuthorizationTransaction(input) {
|
|
431
|
+
input = snapshotInput({ ...input });
|
|
432
|
+
assertRecordBinding(input);
|
|
185
433
|
await this.waitUntilReady();
|
|
186
434
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
187
435
|
upsert: true
|
|
@@ -189,25 +437,70 @@ var MongoOidcVaultStore = class {
|
|
|
189
437
|
}
|
|
190
438
|
async consumeAuthorizationTransaction(state) {
|
|
191
439
|
await this.waitUntilReady();
|
|
192
|
-
const record = await this.authorizationTransactions.findOneAndDelete({
|
|
193
|
-
|
|
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())) {
|
|
194
446
|
return null;
|
|
195
447
|
}
|
|
196
448
|
return authorizationDocumentToRecord(record);
|
|
197
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
|
+
}
|
|
198
466
|
async createExchangeCode(input) {
|
|
467
|
+
input = snapshotInput({ ...input });
|
|
468
|
+
assertRecordBinding(input);
|
|
199
469
|
await this.waitUntilReady();
|
|
200
470
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
201
471
|
}
|
|
202
472
|
async consumeExchangeCode(code) {
|
|
203
473
|
await this.waitUntilReady();
|
|
204
|
-
const record = await this.exchangeCodes.findOneAndDelete({
|
|
205
|
-
|
|
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())) {
|
|
206
480
|
return null;
|
|
207
481
|
}
|
|
208
482
|
return exchangeDocumentToRecord(record);
|
|
209
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
|
+
}
|
|
210
501
|
async createSession(input) {
|
|
502
|
+
input = snapshotInput({ ...input });
|
|
503
|
+
assertSessionBinding(input);
|
|
211
504
|
await this.waitUntilReady();
|
|
212
505
|
const timestamp = this.now();
|
|
213
506
|
const session = {
|
|
@@ -222,21 +515,69 @@ var MongoOidcVaultStore = class {
|
|
|
222
515
|
async getSession(sessionId) {
|
|
223
516
|
await this.waitUntilReady();
|
|
224
517
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
225
|
-
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
518
|
+
if (!session || !validSessionDocument(session, sessionId) || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
226
519
|
return null;
|
|
227
520
|
}
|
|
228
521
|
return documentToSession(session);
|
|
229
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
|
+
}
|
|
230
569
|
async rotateSession(input) {
|
|
570
|
+
input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
|
|
571
|
+
assertSessionBinding(input.nextSession);
|
|
231
572
|
await this.waitUntilReady();
|
|
232
573
|
if (input.nextSession.sessionId === input.sessionId) {
|
|
233
|
-
throw new
|
|
574
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
|
|
234
575
|
}
|
|
235
576
|
try {
|
|
236
577
|
return await this.rotateSessionWithTransaction(input);
|
|
237
578
|
} catch (error) {
|
|
238
579
|
if (isDuplicateKeyError(error)) {
|
|
239
|
-
throw new
|
|
580
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
|
|
240
581
|
}
|
|
241
582
|
throw error;
|
|
242
583
|
}
|
|
@@ -248,7 +589,7 @@ var MongoOidcVaultStore = class {
|
|
|
248
589
|
const logicalSessionId = session.logicalSessionId ?? session._id;
|
|
249
590
|
const result = await this.sessions.deleteOne({ _id: sessionId });
|
|
250
591
|
if (result.deletedCount === 1) {
|
|
251
|
-
await this.
|
|
592
|
+
await this.deleteAliasesForInactiveLogicalSessions([logicalSessionId]);
|
|
252
593
|
return;
|
|
253
594
|
}
|
|
254
595
|
const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
@@ -263,13 +604,14 @@ var MongoOidcVaultStore = class {
|
|
|
263
604
|
}
|
|
264
605
|
}
|
|
265
606
|
async deleteSessionsByLogicalSessionId(input) {
|
|
266
|
-
await this.waitUntilReady();
|
|
267
607
|
const resolved = toLogicalSessionDeleteInput(input);
|
|
608
|
+
await this.waitUntilReady();
|
|
268
609
|
const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
|
|
269
|
-
await this.
|
|
610
|
+
await this.deleteAliasesForInactiveLogicalSessions([resolved.logicalSessionId]);
|
|
270
611
|
return result.deletedCount;
|
|
271
612
|
}
|
|
272
613
|
async consumeBackchannelLogoutTokenJti(input) {
|
|
614
|
+
input = { ...input };
|
|
273
615
|
await this.waitUntilReady();
|
|
274
616
|
const now = this.now();
|
|
275
617
|
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
@@ -302,8 +644,8 @@ var MongoOidcVaultStore = class {
|
|
|
302
644
|
}
|
|
303
645
|
}
|
|
304
646
|
async deleteSessionsBySubject(input) {
|
|
305
|
-
await this.waitUntilReady();
|
|
306
647
|
const resolved = toSubjectDeleteInput(input);
|
|
648
|
+
await this.waitUntilReady();
|
|
307
649
|
const filter = { subject: resolved.subject };
|
|
308
650
|
if (resolved.issuer !== void 0) {
|
|
309
651
|
filter["provider.issuer"] = resolved.issuer;
|
|
@@ -315,8 +657,8 @@ var MongoOidcVaultStore = class {
|
|
|
315
657
|
return result.deletedCount;
|
|
316
658
|
}
|
|
317
659
|
async deleteSessionsByProviderSessionId(input) {
|
|
318
|
-
await this.waitUntilReady();
|
|
319
660
|
const resolved = toProviderSessionDeleteInput(input);
|
|
661
|
+
await this.waitUntilReady();
|
|
320
662
|
const filter = { providerSessionId: resolved.providerSessionId };
|
|
321
663
|
if (resolved.issuer !== void 0) {
|
|
322
664
|
filter["provider.issuer"] = resolved.issuer;
|
|
@@ -334,10 +676,13 @@ var MongoOidcVaultStore = class {
|
|
|
334
676
|
exchangeCodes: this.exchangeCodes,
|
|
335
677
|
sessions: this.sessions,
|
|
336
678
|
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
337
|
-
rotatedSessionAliases: this.rotatedSessionAliases
|
|
679
|
+
rotatedSessionAliases: this.rotatedSessionAliases,
|
|
680
|
+
dpopProofs: this.dpopProofs,
|
|
681
|
+
dpopReplayCapacity: this.dpopReplayCapacity
|
|
338
682
|
}),
|
|
339
683
|
assertTransactionSupport(this.db)
|
|
340
684
|
]);
|
|
685
|
+
await this.dpopReplay.initialize();
|
|
341
686
|
}
|
|
342
687
|
async waitUntilReady() {
|
|
343
688
|
await this.initialization;
|
|
@@ -346,14 +691,22 @@ var MongoOidcVaultStore = class {
|
|
|
346
691
|
}
|
|
347
692
|
}
|
|
348
693
|
async rotateSessionWithTransaction(input) {
|
|
694
|
+
const { previous, nextSession } = await this.normalizeRotatedSession(input);
|
|
695
|
+
const expectedSource = sessionToDocument(previous);
|
|
349
696
|
const session = this.db.client.startSession();
|
|
350
|
-
const nextSession = await this.normalizeRotatedSession(input);
|
|
351
697
|
try {
|
|
352
698
|
await session.withTransaction(async () => {
|
|
699
|
+
const current = await this.sessions.findOne({ _id: input.sessionId }, { session });
|
|
700
|
+
if (!current || isExpired(current, this.now())) {
|
|
701
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
702
|
+
}
|
|
703
|
+
if (!isSameSessionDocumentGeneration(current, expectedSource)) {
|
|
704
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session changed before rotation could commit.");
|
|
705
|
+
}
|
|
353
706
|
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
354
707
|
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
355
708
|
if (deleteResult.deletedCount !== 1) {
|
|
356
|
-
throw new
|
|
709
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
357
710
|
}
|
|
358
711
|
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
359
712
|
});
|
|
@@ -372,7 +725,10 @@ var MongoOidcVaultStore = class {
|
|
|
372
725
|
$set: {
|
|
373
726
|
logicalSessionId,
|
|
374
727
|
expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
|
|
375
|
-
}
|
|
728
|
+
},
|
|
729
|
+
// A no-op alias update would not conflict with an older cleanup
|
|
730
|
+
// snapshot. Every rotation must write, even with identical values.
|
|
731
|
+
$inc: { revision: 1 }
|
|
376
732
|
},
|
|
377
733
|
{ upsert: true, session }
|
|
378
734
|
);
|
|
@@ -380,11 +736,15 @@ var MongoOidcVaultStore = class {
|
|
|
380
736
|
async normalizeRotatedSession(input) {
|
|
381
737
|
const previous = await this.getSession(input.sessionId);
|
|
382
738
|
if (!previous) {
|
|
383
|
-
throw new
|
|
739
|
+
throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
|
|
384
740
|
}
|
|
741
|
+
inheritSessionBinding(previous, input.nextSession);
|
|
385
742
|
return {
|
|
386
|
-
|
|
387
|
-
|
|
743
|
+
previous,
|
|
744
|
+
nextSession: {
|
|
745
|
+
...input.nextSession,
|
|
746
|
+
logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
|
|
747
|
+
}
|
|
388
748
|
};
|
|
389
749
|
}
|
|
390
750
|
async deleteSessionsAndAliasesByFilter(filter) {
|
|
@@ -400,7 +760,7 @@ var MongoOidcVaultStore = class {
|
|
|
400
760
|
}
|
|
401
761
|
const result = await this.sessions.deleteMany(filter);
|
|
402
762
|
deletedCount += result.deletedCount;
|
|
403
|
-
await this.
|
|
763
|
+
await this.deleteAliasesForInactiveLogicalSessions(logicalSessionIds);
|
|
404
764
|
}
|
|
405
765
|
}
|
|
406
766
|
getRotatedSessionAliasExpiresAt(nextSession, now) {
|
|
@@ -408,8 +768,36 @@ var MongoOidcVaultStore = class {
|
|
|
408
768
|
typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
|
|
409
769
|
);
|
|
410
770
|
}
|
|
411
|
-
async
|
|
412
|
-
|
|
771
|
+
async deleteAliasesForInactiveLogicalSessions(logicalSessionIds) {
|
|
772
|
+
const session = this.db.client.startSession();
|
|
773
|
+
try {
|
|
774
|
+
await session.withTransaction(
|
|
775
|
+
async () => {
|
|
776
|
+
const live = await this.sessions.find(
|
|
777
|
+
{
|
|
778
|
+
$and: [
|
|
779
|
+
{
|
|
780
|
+
$or: [
|
|
781
|
+
{ logicalSessionId: { $in: logicalSessionIds } },
|
|
782
|
+
{ logicalSessionId: { $exists: false }, _id: { $in: logicalSessionIds } }
|
|
783
|
+
]
|
|
784
|
+
},
|
|
785
|
+
{ $nor: [{ expiresAt: { $lte: new Date(this.now()) } }] }
|
|
786
|
+
]
|
|
787
|
+
},
|
|
788
|
+
{ session, projection: { _id: 1, logicalSessionId: 1 } }
|
|
789
|
+
).toArray();
|
|
790
|
+
const survivingIds = new Set(live.map((record) => record.logicalSessionId ?? record._id));
|
|
791
|
+
const inactiveIds = logicalSessionIds.filter((id) => !survivingIds.has(id));
|
|
792
|
+
if (inactiveIds.length > 0) {
|
|
793
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: inactiveIds } }, { session });
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
{ readConcern: { level: "snapshot" } }
|
|
797
|
+
);
|
|
798
|
+
} finally {
|
|
799
|
+
await session.endSession();
|
|
800
|
+
}
|
|
413
801
|
}
|
|
414
802
|
async isExpiredAndCleanup(collection, record) {
|
|
415
803
|
if (!isExpired(record, this.now())) {
|