@web-ts-toolkit/express-oidc-vault-mongodb-store 0.31.5 → 0.33.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 +83 -6
- package/index.d.mts +56 -3
- package/index.d.ts +56 -3
- package/index.js +269 -68
- package/index.mjs +267 -69
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -20,6 +20,11 @@ await client.connect();
|
|
|
20
20
|
const storeProvider = createMongoOidcVaultStore({
|
|
21
21
|
db: client.db('app-auth'),
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
await storeProvider.ready();
|
|
25
|
+
|
|
26
|
+
// Later, during application shutdown:
|
|
27
|
+
await client.close();
|
|
23
28
|
```
|
|
24
29
|
|
|
25
30
|
## Express Wiring Example
|
|
@@ -35,9 +40,16 @@ const mongo = new MongoClient(process.env.MONGODB_URI!);
|
|
|
35
40
|
|
|
36
41
|
await mongo.connect();
|
|
37
42
|
|
|
43
|
+
const storeProvider = createMongoOidcVaultStore({
|
|
44
|
+
db: mongo.db('app-auth'),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await storeProvider.ready();
|
|
48
|
+
|
|
38
49
|
app.use(
|
|
39
50
|
createOidcVaultMiddleware({
|
|
40
51
|
basePath: '/auth/oidc',
|
|
52
|
+
backendOrigin: 'https://api.example.com',
|
|
41
53
|
config: {
|
|
42
54
|
issuer: process.env.OIDC_ISSUER,
|
|
43
55
|
clientId: process.env.OIDC_CLIENT_ID,
|
|
@@ -45,21 +57,86 @@ app.use(
|
|
|
45
57
|
},
|
|
46
58
|
frontendRedirectUri: 'https://frontend.example.com/callback',
|
|
47
59
|
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
|
|
48
|
-
storeProvider
|
|
49
|
-
db: mongo.db('app-auth'),
|
|
50
|
-
}),
|
|
60
|
+
storeProvider,
|
|
51
61
|
}),
|
|
52
62
|
);
|
|
63
|
+
|
|
64
|
+
const server = app.listen(3000);
|
|
65
|
+
|
|
66
|
+
process.once('SIGTERM', async () => {
|
|
67
|
+
server.close();
|
|
68
|
+
await mongo.close();
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Collection Names
|
|
73
|
+
|
|
74
|
+
The provider uses five separate MongoDB collections by default:
|
|
75
|
+
|
|
76
|
+
- `oidc_vault_authorization_transactions`: authorization transaction state, nonce, PKCE verifier, and metadata
|
|
77
|
+
- `oidc_vault_exchange_codes`: short-lived frontend exchange codes
|
|
78
|
+
- `oidc_vault_sessions`: sessions, user data, and bearer-equivalent token material
|
|
79
|
+
- `oidc_vault_backchannel_logout_token_jtis`: consumed backchannel logout token JTIs
|
|
80
|
+
- `oidc_vault_rotated_session_aliases`: stale rotated session IDs mapped to active logical sessions
|
|
81
|
+
|
|
82
|
+
Override collection names when your deployment needs explicit naming:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const storeProvider = createMongoOidcVaultStore({
|
|
86
|
+
db: mongo.db('app-auth'),
|
|
87
|
+
authorizationTransactionsCollectionName: 'auth_oidc_transactions',
|
|
88
|
+
exchangeCodesCollectionName: 'auth_oidc_exchange_codes',
|
|
89
|
+
sessionsCollectionName: 'auth_oidc_sessions',
|
|
90
|
+
backchannelLogoutTokenJtisCollectionName: 'auth_oidc_backchannel_logout_jtis',
|
|
91
|
+
rotatedSessionAliasesCollectionName: 'auth_oidc_rotated_session_aliases',
|
|
92
|
+
});
|
|
53
93
|
```
|
|
54
94
|
|
|
55
95
|
## Notes
|
|
56
96
|
|
|
57
97
|
- use MongoDB when your team already standardizes on Mongo and you want the auth vault data in the same operational platform
|
|
58
|
-
- the provider uses separate collections for authorization transactions, exchange codes, and
|
|
59
|
-
- TTL indexes are created on
|
|
60
|
-
- session rotation
|
|
98
|
+
- the provider uses separate collections for authorization transactions, exchange codes, sessions, backchannel logout token JTIs, and rotated-session aliases
|
|
99
|
+
- TTL indexes are created on expiring records; authorization transactions, exchange codes, backchannel logout token JTIs, and rotated-session aliases are also checked for expiration during relevant reads or consumes so behavior does not depend only on Mongo's background TTL monitor timing
|
|
100
|
+
- session rotation requires MongoDB transactions; use a replica set or sharded deployment because standalone servers fail closed instead of using non-atomic multi-write rotation
|
|
101
|
+
- startup readiness creates required indexes and verifies transaction-capable topology; connect the MongoDB client, create the store, await `storeProvider.ready()`, then accept traffic
|
|
102
|
+
- session deletion by subject or provider session ID uses compound scoped indexes on the identity field plus `provider.issuer` and `provider.clientId`; these replace single-field identity indexes because the leading key still supports identity-only deletes while scoped logout deletes avoid scanning every repeated identity across tenants
|
|
103
|
+
- the application owns MongoDB client shutdown; this package does not close the client
|
|
104
|
+
- rotation requires a distinct unused target session ID; missing-source, same-ID, and existing-target rotation conflicts throw `OidcVaultStoreConflictError` without changing source or target records
|
|
105
|
+
- rotated-session aliases are retained only long enough to bridge in-flight refresh/logout requests; sessions without explicit expiry use a finite 5 minute alias retention window by default, configurable with `rotatedSessionAliasRetentionMs`
|
|
106
|
+
- deleting by current session ID, stale rotated ID, logical session ID, subject, or provider session ID removes aliases for the affected logical sessions
|
|
107
|
+
- `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; metadata should be JSON-compatible for portability across store providers
|
|
108
|
+
- backchannel logout token JTI records are consumed only when `expiresAt` is finite and greater than the store clock at consume time
|
|
109
|
+
|
|
110
|
+
## Security And Operations
|
|
111
|
+
|
|
112
|
+
Session records contain refresh tokens, ID tokens, access tokens, and related bearer-equivalent secrets. Treat the MongoDB database and every backup, log, trace, metric label, and export path that can expose these records as sensitive auth infrastructure.
|
|
113
|
+
|
|
114
|
+
Recommended controls:
|
|
115
|
+
|
|
116
|
+
- require TLS for MongoDB connections and for any network path carrying session data
|
|
117
|
+
- grant the application least-privilege roles scoped to the configured database and collections
|
|
118
|
+
- enable encryption at rest for the database and backups
|
|
119
|
+
- restrict observability tooling so token fields and whole session documents are not logged, indexed, sampled, or exported
|
|
120
|
+
- define an explicit data-retention policy for sessions, aliases, authorization transactions, exchange codes, and consumed logout JTIs
|
|
121
|
+
- close the caller-owned `MongoClient` from application shutdown code; this package never closes it
|
|
122
|
+
|
|
123
|
+
This package does not implement application-level field encryption or client-side field-level encryption. Add those controls at your MongoDB/client configuration layer if your deployment requires them.
|
|
124
|
+
|
|
125
|
+
## Scoped Deletion Indexes
|
|
126
|
+
|
|
127
|
+
The sessions collection creates these deletion indexes:
|
|
128
|
+
|
|
129
|
+
- `subject_scope_idx`: `{ subject: 1, 'provider.issuer': 1, 'provider.clientId': 1 }`
|
|
130
|
+
- `provider_session_scope_idx`: `{ providerSessionId: 1, 'provider.issuer': 1, 'provider.clientId': 1 }`
|
|
131
|
+
- `logical_session_idx`: `{ logicalSessionId: 1 }`
|
|
132
|
+
|
|
133
|
+
Representative query-plan evidence used a dataset with 2 repeated identities, 10 issuers, 10 clients, and 10 duplicate sessions per issuer/client scope. With only single-field `subject` or `providerSessionId` indexes, scoped delete lookups examined all 1,000 matching identity documents. With the compound indexes above, `subject/providerSessionId + issuer`, `subject/providerSessionId + clientId`, and `subject/providerSessionId + issuer + clientId` examined 100, 100, and 10 documents respectively, matching the scoped result set size in that dataset.
|
|
134
|
+
|
|
135
|
+
The package intentionally creates one compound index per public scoped identity delete path rather than one index per optional-filter permutation. The leading identity key still supports identity-only deletes with the same result-set-sized scan as the previous single-field indexes, while issuer/client scoped deletes avoid broad scans in multi-tenant collections. The residual assumption is that very large deployments should keep subject/provider-session identifiers reasonably distributed within each issuer/client scope; otherwise the scoped result set itself is large and deletion cost is expected to scale with the number of sessions being revoked.
|
|
61
136
|
|
|
62
137
|
## Main Exports
|
|
63
138
|
|
|
64
139
|
- `createMongoOidcVaultStore(...)`
|
|
140
|
+
- `type OidcVaultMongoStoreProvider`
|
|
65
141
|
- `type MongoOidcVaultStoreOptions`
|
|
142
|
+
- `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`
|
package/index.d.mts
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
|
-
import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
2
1
|
import { Db } from 'mongodb';
|
|
2
|
+
import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Options for `createMongoOidcVaultStore`.
|
|
6
|
+
*
|
|
7
|
+
* Pass a `Db` from an already-connected caller-owned `MongoClient`. The store
|
|
8
|
+
* validates collection names, creates indexes, and verifies transaction support
|
|
9
|
+
* from `ready()`, so applications should await readiness before accepting
|
|
10
|
+
* traffic. Session rotation requires MongoDB transactions; standalone servers
|
|
11
|
+
* fail closed instead of using non-atomic multi-write fallback behavior.
|
|
12
|
+
*/
|
|
4
13
|
interface MongoOidcVaultStoreOptions {
|
|
14
|
+
/** MongoDB database handle. The caller owns the underlying client lifecycle. */
|
|
5
15
|
db: Db;
|
|
16
|
+
/** Collection for authorization transactions, keyed by OIDC `state`. */
|
|
6
17
|
authorizationTransactionsCollectionName?: string;
|
|
18
|
+
/** Collection for short-lived frontend exchange codes. */
|
|
7
19
|
exchangeCodesCollectionName?: string;
|
|
20
|
+
/** Collection for OIDC vault sessions and bearer-equivalent token material. */
|
|
8
21
|
sessionsCollectionName?: string;
|
|
22
|
+
/** Collection for consumed backchannel logout token JTIs. */
|
|
23
|
+
backchannelLogoutTokenJtisCollectionName?: string;
|
|
24
|
+
/** Collection mapping rotated stale session IDs to active logical sessions. */
|
|
25
|
+
rotatedSessionAliasesCollectionName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Finite alias retention for sessions without explicit expiry, in
|
|
28
|
+
* milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
|
|
29
|
+
*/
|
|
30
|
+
rotatedSessionAliasRetentionMs?: number;
|
|
31
|
+
/** Test-only clock override used for deterministic expiry and retention tests. */
|
|
9
32
|
now?: () => number;
|
|
10
33
|
}
|
|
11
|
-
|
|
34
|
+
/**
|
|
35
|
+
* MongoDB-backed OIDC vault store provider.
|
|
36
|
+
*
|
|
37
|
+
* Create the store after connecting the MongoDB client, then await `ready()`
|
|
38
|
+
* before accepting traffic. The application owns the MongoDB client lifecycle,
|
|
39
|
+
* including shutdown.
|
|
40
|
+
*/
|
|
41
|
+
interface OidcVaultMongoStoreProvider extends OidcVaultStoreProvider {
|
|
42
|
+
/**
|
|
43
|
+
* Waits for startup validation to complete.
|
|
44
|
+
*
|
|
45
|
+
* Readiness validates collection names, creates required indexes, and verifies
|
|
46
|
+
* the MongoDB deployment supports transactions required by session rotation.
|
|
47
|
+
* If readiness fails, every store operation rejects with the same error.
|
|
48
|
+
*/
|
|
49
|
+
ready(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a MongoDB-backed store provider.
|
|
55
|
+
*
|
|
56
|
+
* Use the named root import:
|
|
57
|
+
* `import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store'`.
|
|
58
|
+
* Pass a `Db` from an already-connected caller-owned `MongoClient`, await
|
|
59
|
+
* `store.ready()` before accepting traffic, and close the client from your own
|
|
60
|
+
* shutdown path. Readiness validates all configured collection names, creates
|
|
61
|
+
* required indexes for the five store collections, and verifies that the
|
|
62
|
+
* MongoDB deployment supports transactions required by session rotation.
|
|
63
|
+
*/
|
|
64
|
+
declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
|
|
12
65
|
|
|
13
|
-
export { type MongoOidcVaultStoreOptions, createMongoOidcVaultStore };
|
|
66
|
+
export { DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS, type MongoOidcVaultStoreOptions, type OidcVaultMongoStoreProvider, createMongoOidcVaultStore };
|
package/index.d.ts
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
|
-
import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
2
1
|
import { Db } from 'mongodb';
|
|
2
|
+
import { OidcVaultStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Options for `createMongoOidcVaultStore`.
|
|
6
|
+
*
|
|
7
|
+
* Pass a `Db` from an already-connected caller-owned `MongoClient`. The store
|
|
8
|
+
* validates collection names, creates indexes, and verifies transaction support
|
|
9
|
+
* from `ready()`, so applications should await readiness before accepting
|
|
10
|
+
* traffic. Session rotation requires MongoDB transactions; standalone servers
|
|
11
|
+
* fail closed instead of using non-atomic multi-write fallback behavior.
|
|
12
|
+
*/
|
|
4
13
|
interface MongoOidcVaultStoreOptions {
|
|
14
|
+
/** MongoDB database handle. The caller owns the underlying client lifecycle. */
|
|
5
15
|
db: Db;
|
|
16
|
+
/** Collection for authorization transactions, keyed by OIDC `state`. */
|
|
6
17
|
authorizationTransactionsCollectionName?: string;
|
|
18
|
+
/** Collection for short-lived frontend exchange codes. */
|
|
7
19
|
exchangeCodesCollectionName?: string;
|
|
20
|
+
/** Collection for OIDC vault sessions and bearer-equivalent token material. */
|
|
8
21
|
sessionsCollectionName?: string;
|
|
22
|
+
/** Collection for consumed backchannel logout token JTIs. */
|
|
23
|
+
backchannelLogoutTokenJtisCollectionName?: string;
|
|
24
|
+
/** Collection mapping rotated stale session IDs to active logical sessions. */
|
|
25
|
+
rotatedSessionAliasesCollectionName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Finite alias retention for sessions without explicit expiry, in
|
|
28
|
+
* milliseconds. Defaults to `DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS`.
|
|
29
|
+
*/
|
|
30
|
+
rotatedSessionAliasRetentionMs?: number;
|
|
31
|
+
/** Test-only clock override used for deterministic expiry and retention tests. */
|
|
9
32
|
now?: () => number;
|
|
10
33
|
}
|
|
11
|
-
|
|
34
|
+
/**
|
|
35
|
+
* MongoDB-backed OIDC vault store provider.
|
|
36
|
+
*
|
|
37
|
+
* Create the store after connecting the MongoDB client, then await `ready()`
|
|
38
|
+
* before accepting traffic. The application owns the MongoDB client lifecycle,
|
|
39
|
+
* including shutdown.
|
|
40
|
+
*/
|
|
41
|
+
interface OidcVaultMongoStoreProvider extends OidcVaultStoreProvider {
|
|
42
|
+
/**
|
|
43
|
+
* Waits for startup validation to complete.
|
|
44
|
+
*
|
|
45
|
+
* Readiness validates collection names, creates required indexes, and verifies
|
|
46
|
+
* the MongoDB deployment supports transactions required by session rotation.
|
|
47
|
+
* If readiness fails, every store operation rejects with the same error.
|
|
48
|
+
*/
|
|
49
|
+
ready(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
declare const DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a MongoDB-backed store provider.
|
|
55
|
+
*
|
|
56
|
+
* Use the named root import:
|
|
57
|
+
* `import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store'`.
|
|
58
|
+
* Pass a `Db` from an already-connected caller-owned `MongoClient`, await
|
|
59
|
+
* `store.ready()` before accepting traffic, and close the client from your own
|
|
60
|
+
* shutdown path. Readiness validates all configured collection names, creates
|
|
61
|
+
* required indexes for the five store collections, and verifies that the
|
|
62
|
+
* MongoDB deployment supports transactions required by session rotation.
|
|
63
|
+
*/
|
|
64
|
+
declare function createMongoOidcVaultStore(options: MongoOidcVaultStoreOptions): OidcVaultMongoStoreProvider;
|
|
12
65
|
|
|
13
|
-
export { type MongoOidcVaultStoreOptions, createMongoOidcVaultStore };
|
|
66
|
+
export { DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS, type MongoOidcVaultStoreOptions, type OidcVaultMongoStoreProvider, createMongoOidcVaultStore };
|
package/index.js
CHANGED
|
@@ -20,17 +20,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS: () => DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
|
|
23
24
|
createMongoOidcVaultStore: () => createMongoOidcVaultStore
|
|
24
25
|
});
|
|
25
26
|
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/store.ts
|
|
26
29
|
var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
exchangeCodes: "oidc_vault_exchange_codes",
|
|
30
|
-
sessions: "oidc_vault_sessions"
|
|
31
|
-
};
|
|
30
|
+
|
|
31
|
+
// src/documents.ts
|
|
32
32
|
var sessionToDocument = (session) => ({
|
|
33
33
|
_id: session.sessionId,
|
|
34
|
+
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
34
35
|
subject: session.subject,
|
|
35
36
|
providerSessionId: session.providerSessionId,
|
|
36
37
|
provider: session.provider,
|
|
@@ -46,6 +47,7 @@ var sessionToDocument = (session) => ({
|
|
|
46
47
|
});
|
|
47
48
|
var documentToSession = (session) => ({
|
|
48
49
|
sessionId: session._id,
|
|
50
|
+
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
49
51
|
subject: session.subject,
|
|
50
52
|
providerSessionId: session.providerSessionId,
|
|
51
53
|
provider: session.provider,
|
|
@@ -87,56 +89,155 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
87
89
|
expiresAt: record.expiresAt.getTime()
|
|
88
90
|
});
|
|
89
91
|
var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
|
|
92
|
+
|
|
93
|
+
// src/options.ts
|
|
94
|
+
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
95
|
+
var DEFAULT_COLLECTION_NAMES = {
|
|
96
|
+
authorizationTransactions: "oidc_vault_authorization_transactions",
|
|
97
|
+
exchangeCodes: "oidc_vault_exchange_codes",
|
|
98
|
+
sessions: "oidc_vault_sessions",
|
|
99
|
+
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
100
|
+
rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
|
|
101
|
+
};
|
|
102
|
+
var validateCollectionName = (role, name) => {
|
|
103
|
+
if (name.length === 0) {
|
|
104
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not be empty.`);
|
|
105
|
+
}
|
|
106
|
+
if (name.includes("\0")) {
|
|
107
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain null bytes.`);
|
|
108
|
+
}
|
|
109
|
+
if (name.startsWith("system.")) {
|
|
110
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not use the reserved system namespace.`);
|
|
111
|
+
}
|
|
112
|
+
if (name.includes("$")) {
|
|
113
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain '$'.`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
var resolveCollectionNames = (options) => {
|
|
117
|
+
const collectionNames = {
|
|
118
|
+
authorizationTransactions: options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions,
|
|
119
|
+
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
120
|
+
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
121
|
+
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
122
|
+
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
|
|
123
|
+
};
|
|
124
|
+
for (const [role, name] of Object.entries(collectionNames)) {
|
|
125
|
+
validateCollectionName(role, name);
|
|
126
|
+
}
|
|
127
|
+
const seen = /* @__PURE__ */ new Map();
|
|
128
|
+
for (const [role, name] of Object.entries(collectionNames)) {
|
|
129
|
+
const existingRole = seen.get(name);
|
|
130
|
+
if (existingRole !== void 0) {
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
`OIDC vault MongoDB ${role} collection name must be distinct from ${existingRole}; shared collections are not supported.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
seen.set(name, role);
|
|
136
|
+
}
|
|
137
|
+
return collectionNames;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/topology.ts
|
|
141
|
+
var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
|
|
142
|
+
var assertTransactionSupport = async (db) => {
|
|
143
|
+
const hello = await db.admin().command({ hello: 1 });
|
|
144
|
+
if (!isTransactionCapableHelloResponse(hello)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"OIDC vault MongoDB session rotation requires a transaction-capable MongoDB deployment. Use a replica set or sharded cluster."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var createTtlIndex = async (collection) => collection.createIndex(
|
|
151
|
+
{ expiresAt: 1 },
|
|
152
|
+
{
|
|
153
|
+
expireAfterSeconds: 0,
|
|
154
|
+
name: "expiresAt_ttl"
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
var ensureStoreIndexes = async (collections) => {
|
|
158
|
+
await Promise.all([
|
|
159
|
+
createTtlIndex(collections.authorizationTransactions),
|
|
160
|
+
createTtlIndex(collections.exchangeCodes),
|
|
161
|
+
createTtlIndex(collections.sessions),
|
|
162
|
+
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
163
|
+
createTtlIndex(collections.rotatedSessionAliases),
|
|
164
|
+
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
165
|
+
collections.sessions.createIndex(
|
|
166
|
+
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
167
|
+
{ name: "subject_scope_idx" }
|
|
168
|
+
),
|
|
169
|
+
collections.sessions.createIndex(
|
|
170
|
+
{ providerSessionId: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
171
|
+
{ name: "provider_session_scope_idx" }
|
|
172
|
+
),
|
|
173
|
+
collections.sessions.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" })
|
|
174
|
+
]);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/store.ts
|
|
90
178
|
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
|
|
91
179
|
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
|
|
92
|
-
var
|
|
180
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
|
|
181
|
+
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
93
182
|
var MongoOidcVaultStore = class {
|
|
94
183
|
constructor(options) {
|
|
184
|
+
const collectionNames = resolveCollectionNames(options);
|
|
95
185
|
this.db = options.db;
|
|
96
186
|
this.authorizationTransactions = options.db.collection(
|
|
97
|
-
|
|
187
|
+
collectionNames.authorizationTransactions
|
|
98
188
|
);
|
|
99
|
-
this.exchangeCodes = options.db.collection(
|
|
100
|
-
|
|
189
|
+
this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
|
|
190
|
+
this.sessions = options.db.collection(collectionNames.sessions);
|
|
191
|
+
this.backchannelLogoutTokenJtis = options.db.collection(
|
|
192
|
+
collectionNames.backchannelLogoutTokenJtis
|
|
101
193
|
);
|
|
102
|
-
this.
|
|
103
|
-
|
|
194
|
+
this.rotatedSessionAliases = options.db.collection(
|
|
195
|
+
collectionNames.rotatedSessionAliases
|
|
104
196
|
);
|
|
197
|
+
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
198
|
+
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
199
|
+
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
200
|
+
}
|
|
105
201
|
this.now = options.now ?? (() => Date.now());
|
|
106
|
-
this.
|
|
107
|
-
|
|
202
|
+
this.initialization = this.initialize().then(void 0, (error) => {
|
|
203
|
+
this.initializationError = error;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async ready() {
|
|
207
|
+
await this.waitUntilReady();
|
|
108
208
|
}
|
|
109
209
|
async createAuthorizationTransaction(input) {
|
|
110
|
-
await this.
|
|
210
|
+
await this.waitUntilReady();
|
|
111
211
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
112
212
|
upsert: true
|
|
113
213
|
});
|
|
114
214
|
}
|
|
115
215
|
async consumeAuthorizationTransaction(state) {
|
|
116
|
-
await this.
|
|
216
|
+
await this.waitUntilReady();
|
|
117
217
|
const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
|
|
118
|
-
if (!record ||
|
|
218
|
+
if (!record || isExpired(record, this.now())) {
|
|
119
219
|
return null;
|
|
120
220
|
}
|
|
121
221
|
return authorizationDocumentToRecord(record);
|
|
122
222
|
}
|
|
123
223
|
async createExchangeCode(input) {
|
|
124
|
-
await this.
|
|
224
|
+
await this.waitUntilReady();
|
|
125
225
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
126
226
|
}
|
|
127
227
|
async consumeExchangeCode(code) {
|
|
128
|
-
await this.
|
|
228
|
+
await this.waitUntilReady();
|
|
129
229
|
const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
|
|
130
|
-
if (!record ||
|
|
230
|
+
if (!record || isExpired(record, this.now())) {
|
|
131
231
|
return null;
|
|
132
232
|
}
|
|
133
233
|
return exchangeDocumentToRecord(record);
|
|
134
234
|
}
|
|
135
235
|
async createSession(input) {
|
|
136
|
-
await this.
|
|
236
|
+
await this.waitUntilReady();
|
|
137
237
|
const timestamp = this.now();
|
|
138
238
|
const session = {
|
|
139
239
|
...input,
|
|
240
|
+
logicalSessionId: input.logicalSessionId ?? input.sessionId,
|
|
140
241
|
createdAt: input.createdAt ?? timestamp,
|
|
141
242
|
updatedAt: input.updatedAt ?? timestamp
|
|
142
243
|
};
|
|
@@ -144,7 +245,7 @@ var MongoOidcVaultStore = class {
|
|
|
144
245
|
return session;
|
|
145
246
|
}
|
|
146
247
|
async getSession(sessionId) {
|
|
147
|
-
await this.
|
|
248
|
+
await this.waitUntilReady();
|
|
148
249
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
149
250
|
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
150
251
|
return null;
|
|
@@ -152,42 +253,81 @@ var MongoOidcVaultStore = class {
|
|
|
152
253
|
return documentToSession(session);
|
|
153
254
|
}
|
|
154
255
|
async rotateSession(input) {
|
|
155
|
-
await this.
|
|
156
|
-
if (
|
|
157
|
-
|
|
256
|
+
await this.waitUntilReady();
|
|
257
|
+
if (input.nextSession.sessionId === input.sessionId) {
|
|
258
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
return await this.rotateSessionWithTransaction(input);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (isDuplicateKeyError(error)) {
|
|
264
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
158
267
|
}
|
|
159
|
-
return this.rotateSessionWithoutTransaction(input);
|
|
160
268
|
}
|
|
161
|
-
async
|
|
162
|
-
await this.
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
269
|
+
async deleteSession(sessionId) {
|
|
270
|
+
await this.waitUntilReady();
|
|
271
|
+
const session = await this.sessions.findOne({ _id: sessionId });
|
|
272
|
+
if (session) {
|
|
273
|
+
const logicalSessionId = session.logicalSessionId ?? session._id;
|
|
274
|
+
const result = await this.sessions.deleteOne({ _id: sessionId });
|
|
275
|
+
if (result.deletedCount === 1) {
|
|
276
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
280
|
+
if (alias2 && !isExpired(alias2, this.now())) {
|
|
281
|
+
await this.deleteSessionsByLogicalSessionId(alias2.logicalSessionId);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
286
|
+
if (alias && !isExpired(alias, this.now())) {
|
|
287
|
+
await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
|
|
167
288
|
}
|
|
168
|
-
return input.nextSession;
|
|
169
289
|
}
|
|
170
|
-
async
|
|
171
|
-
|
|
290
|
+
async deleteSessionsByLogicalSessionId(input) {
|
|
291
|
+
await this.waitUntilReady();
|
|
292
|
+
const resolved = toLogicalSessionDeleteInput(input);
|
|
293
|
+
const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
|
|
294
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
|
|
295
|
+
return result.deletedCount;
|
|
296
|
+
}
|
|
297
|
+
async consumeBackchannelLogoutTokenJti(input) {
|
|
298
|
+
await this.waitUntilReady();
|
|
299
|
+
const now = this.now();
|
|
300
|
+
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const replacedExpired = await this.backchannelLogoutTokenJtis.findOneAndUpdate(
|
|
304
|
+
{
|
|
305
|
+
_id: input.jti,
|
|
306
|
+
expiresAt: { $lte: new Date(now) }
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
$set: { expiresAt: new Date(input.expiresAt) }
|
|
310
|
+
},
|
|
311
|
+
{ returnDocument: "after" }
|
|
312
|
+
);
|
|
313
|
+
if (replacedExpired) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
172
316
|
try {
|
|
173
|
-
await
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (deleteResult.deletedCount !== 1) {
|
|
177
|
-
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
178
|
-
}
|
|
317
|
+
await this.backchannelLogoutTokenJtis.insertOne({
|
|
318
|
+
_id: input.jti,
|
|
319
|
+
expiresAt: new Date(input.expiresAt)
|
|
179
320
|
});
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
|
|
321
|
+
return true;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (isDuplicateKeyError(error)) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
183
327
|
}
|
|
184
328
|
}
|
|
185
|
-
async deleteSession(sessionId) {
|
|
186
|
-
await this.ready;
|
|
187
|
-
await this.sessions.deleteOne({ _id: sessionId });
|
|
188
|
-
}
|
|
189
329
|
async deleteSessionsBySubject(input) {
|
|
190
|
-
await this.
|
|
330
|
+
await this.waitUntilReady();
|
|
191
331
|
const resolved = toSubjectDeleteInput(input);
|
|
192
332
|
const filter = { subject: resolved.subject };
|
|
193
333
|
if (resolved.issuer !== void 0) {
|
|
@@ -196,11 +336,11 @@ var MongoOidcVaultStore = class {
|
|
|
196
336
|
if (resolved.clientId !== void 0) {
|
|
197
337
|
filter["provider.clientId"] = resolved.clientId;
|
|
198
338
|
}
|
|
199
|
-
const result = await this.
|
|
339
|
+
const result = await this.deleteSessionsAndAliasesByFilter(filter);
|
|
200
340
|
return result.deletedCount;
|
|
201
341
|
}
|
|
202
342
|
async deleteSessionsByProviderSessionId(input) {
|
|
203
|
-
await this.
|
|
343
|
+
await this.waitUntilReady();
|
|
204
344
|
const resolved = toProviderSessionDeleteInput(input);
|
|
205
345
|
const filter = { providerSessionId: resolved.providerSessionId };
|
|
206
346
|
if (resolved.issuer !== void 0) {
|
|
@@ -209,47 +349,108 @@ var MongoOidcVaultStore = class {
|
|
|
209
349
|
if (resolved.clientId !== void 0) {
|
|
210
350
|
filter["provider.clientId"] = resolved.clientId;
|
|
211
351
|
}
|
|
212
|
-
const result = await this.
|
|
352
|
+
const result = await this.deleteSessionsAndAliasesByFilter(filter);
|
|
213
353
|
return result.deletedCount;
|
|
214
354
|
}
|
|
215
|
-
async
|
|
355
|
+
async initialize() {
|
|
216
356
|
await Promise.all([
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
357
|
+
ensureStoreIndexes({
|
|
358
|
+
authorizationTransactions: this.authorizationTransactions,
|
|
359
|
+
exchangeCodes: this.exchangeCodes,
|
|
360
|
+
sessions: this.sessions,
|
|
361
|
+
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
362
|
+
rotatedSessionAliases: this.rotatedSessionAliases
|
|
363
|
+
}),
|
|
364
|
+
assertTransactionSupport(this.db)
|
|
222
365
|
]);
|
|
223
366
|
}
|
|
224
|
-
async
|
|
367
|
+
async waitUntilReady() {
|
|
368
|
+
await this.initialization;
|
|
369
|
+
if (this.initializationError !== void 0) {
|
|
370
|
+
throw this.initializationError;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async rotateSessionWithTransaction(input) {
|
|
374
|
+
const session = this.db.client.startSession();
|
|
375
|
+
const nextSession = await this.normalizeRotatedSession(input);
|
|
225
376
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
377
|
+
await session.withTransaction(async () => {
|
|
378
|
+
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
379
|
+
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
380
|
+
if (deleteResult.deletedCount !== 1) {
|
|
381
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
382
|
+
}
|
|
383
|
+
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
384
|
+
});
|
|
385
|
+
return nextSession;
|
|
386
|
+
} finally {
|
|
387
|
+
await session.endSession();
|
|
230
388
|
}
|
|
231
389
|
}
|
|
232
|
-
async
|
|
233
|
-
|
|
234
|
-
|
|
390
|
+
async createRotatedSessionAlias(previousSessionId, nextSession, session) {
|
|
391
|
+
const logicalSessionId = nextSession.logicalSessionId ?? nextSession.sessionId;
|
|
392
|
+
const now = this.now();
|
|
393
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId, expiresAt: { $lte: new Date(now) } }, { session });
|
|
394
|
+
await this.rotatedSessionAliases.updateOne(
|
|
395
|
+
{ _id: previousSessionId },
|
|
235
396
|
{
|
|
236
|
-
|
|
237
|
-
|
|
397
|
+
$set: {
|
|
398
|
+
logicalSessionId,
|
|
399
|
+
expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
{ upsert: true, session }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
async normalizeRotatedSession(input) {
|
|
406
|
+
const previous = await this.getSession(input.sessionId);
|
|
407
|
+
if (!previous) {
|
|
408
|
+
throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
...input.nextSession,
|
|
412
|
+
logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async deleteSessionsAndAliasesByFilter(filter) {
|
|
416
|
+
let deletedCount = 0;
|
|
417
|
+
for (; ; ) {
|
|
418
|
+
const sessions = await this.sessions.find(filter).project({
|
|
419
|
+
_id: 1,
|
|
420
|
+
logicalSessionId: 1
|
|
421
|
+
}).toArray();
|
|
422
|
+
const logicalSessionIds = [...new Set(sessions.map((session) => session.logicalSessionId ?? session._id))];
|
|
423
|
+
if (logicalSessionIds.length === 0) {
|
|
424
|
+
return { deletedCount };
|
|
238
425
|
}
|
|
426
|
+
const result = await this.sessions.deleteMany(filter);
|
|
427
|
+
deletedCount += result.deletedCount;
|
|
428
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
getRotatedSessionAliasExpiresAt(nextSession, now) {
|
|
432
|
+
return new Date(
|
|
433
|
+
typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
|
|
239
434
|
);
|
|
240
435
|
}
|
|
436
|
+
async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
|
|
437
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
|
|
438
|
+
}
|
|
241
439
|
async isExpiredAndCleanup(collection, record) {
|
|
242
440
|
if (!isExpired(record, this.now())) {
|
|
243
441
|
return false;
|
|
244
442
|
}
|
|
245
|
-
await collection.deleteOne({ _id: record._id });
|
|
443
|
+
await collection.deleteOne({ _id: record._id, expiresAt: record.expiresAt });
|
|
246
444
|
return true;
|
|
247
445
|
}
|
|
248
446
|
};
|
|
447
|
+
|
|
448
|
+
// src/index.ts
|
|
249
449
|
function createMongoOidcVaultStore(options) {
|
|
250
450
|
return new MongoOidcVaultStore(options);
|
|
251
451
|
}
|
|
252
452
|
// Annotate the CommonJS export names for ESM import in node:
|
|
253
453
|
0 && (module.exports = {
|
|
454
|
+
DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
|
|
254
455
|
createMongoOidcVaultStore
|
|
255
456
|
});
|
package/index.mjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/store.ts
|
|
2
2
|
import {
|
|
3
3
|
OidcVaultStoreConflictError
|
|
4
4
|
} from "@web-ts-toolkit/express-oidc-vault";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
exchangeCodes: "oidc_vault_exchange_codes",
|
|
8
|
-
sessions: "oidc_vault_sessions"
|
|
9
|
-
};
|
|
5
|
+
|
|
6
|
+
// src/documents.ts
|
|
10
7
|
var sessionToDocument = (session) => ({
|
|
11
8
|
_id: session.sessionId,
|
|
9
|
+
logicalSessionId: session.logicalSessionId ?? session.sessionId,
|
|
12
10
|
subject: session.subject,
|
|
13
11
|
providerSessionId: session.providerSessionId,
|
|
14
12
|
provider: session.provider,
|
|
@@ -24,6 +22,7 @@ var sessionToDocument = (session) => ({
|
|
|
24
22
|
});
|
|
25
23
|
var documentToSession = (session) => ({
|
|
26
24
|
sessionId: session._id,
|
|
25
|
+
logicalSessionId: session.logicalSessionId ?? session._id,
|
|
27
26
|
subject: session.subject,
|
|
28
27
|
providerSessionId: session.providerSessionId,
|
|
29
28
|
provider: session.provider,
|
|
@@ -65,56 +64,155 @@ var exchangeDocumentToRecord = (record) => ({
|
|
|
65
64
|
expiresAt: record.expiresAt.getTime()
|
|
66
65
|
});
|
|
67
66
|
var isExpired = (record, now) => record.expiresAt instanceof Date && record.expiresAt.getTime() <= now;
|
|
67
|
+
|
|
68
|
+
// src/options.ts
|
|
69
|
+
var DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS = 5 * 60 * 1e3;
|
|
70
|
+
var DEFAULT_COLLECTION_NAMES = {
|
|
71
|
+
authorizationTransactions: "oidc_vault_authorization_transactions",
|
|
72
|
+
exchangeCodes: "oidc_vault_exchange_codes",
|
|
73
|
+
sessions: "oidc_vault_sessions",
|
|
74
|
+
backchannelLogoutTokenJtis: "oidc_vault_backchannel_logout_token_jtis",
|
|
75
|
+
rotatedSessionAliases: "oidc_vault_rotated_session_aliases"
|
|
76
|
+
};
|
|
77
|
+
var validateCollectionName = (role, name) => {
|
|
78
|
+
if (name.length === 0) {
|
|
79
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not be empty.`);
|
|
80
|
+
}
|
|
81
|
+
if (name.includes("\0")) {
|
|
82
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain null bytes.`);
|
|
83
|
+
}
|
|
84
|
+
if (name.startsWith("system.")) {
|
|
85
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not use the reserved system namespace.`);
|
|
86
|
+
}
|
|
87
|
+
if (name.includes("$")) {
|
|
88
|
+
throw new TypeError(`OIDC vault MongoDB ${role} collection name must not contain '$'.`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var resolveCollectionNames = (options) => {
|
|
92
|
+
const collectionNames = {
|
|
93
|
+
authorizationTransactions: options.authorizationTransactionsCollectionName ?? DEFAULT_COLLECTION_NAMES.authorizationTransactions,
|
|
94
|
+
exchangeCodes: options.exchangeCodesCollectionName ?? DEFAULT_COLLECTION_NAMES.exchangeCodes,
|
|
95
|
+
sessions: options.sessionsCollectionName ?? DEFAULT_COLLECTION_NAMES.sessions,
|
|
96
|
+
backchannelLogoutTokenJtis: options.backchannelLogoutTokenJtisCollectionName ?? DEFAULT_COLLECTION_NAMES.backchannelLogoutTokenJtis,
|
|
97
|
+
rotatedSessionAliases: options.rotatedSessionAliasesCollectionName ?? DEFAULT_COLLECTION_NAMES.rotatedSessionAliases
|
|
98
|
+
};
|
|
99
|
+
for (const [role, name] of Object.entries(collectionNames)) {
|
|
100
|
+
validateCollectionName(role, name);
|
|
101
|
+
}
|
|
102
|
+
const seen = /* @__PURE__ */ new Map();
|
|
103
|
+
for (const [role, name] of Object.entries(collectionNames)) {
|
|
104
|
+
const existingRole = seen.get(name);
|
|
105
|
+
if (existingRole !== void 0) {
|
|
106
|
+
throw new TypeError(
|
|
107
|
+
`OIDC vault MongoDB ${role} collection name must be distinct from ${existingRole}; shared collections are not supported.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
seen.set(name, role);
|
|
111
|
+
}
|
|
112
|
+
return collectionNames;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/topology.ts
|
|
116
|
+
var isTransactionCapableHelloResponse = (value) => typeof value.setName === "string" || value.msg === "isdbgrid";
|
|
117
|
+
var assertTransactionSupport = async (db) => {
|
|
118
|
+
const hello = await db.admin().command({ hello: 1 });
|
|
119
|
+
if (!isTransactionCapableHelloResponse(hello)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"OIDC vault MongoDB session rotation requires a transaction-capable MongoDB deployment. Use a replica set or sharded cluster."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var createTtlIndex = async (collection) => collection.createIndex(
|
|
126
|
+
{ expiresAt: 1 },
|
|
127
|
+
{
|
|
128
|
+
expireAfterSeconds: 0,
|
|
129
|
+
name: "expiresAt_ttl"
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
var ensureStoreIndexes = async (collections) => {
|
|
133
|
+
await Promise.all([
|
|
134
|
+
createTtlIndex(collections.authorizationTransactions),
|
|
135
|
+
createTtlIndex(collections.exchangeCodes),
|
|
136
|
+
createTtlIndex(collections.sessions),
|
|
137
|
+
createTtlIndex(collections.backchannelLogoutTokenJtis),
|
|
138
|
+
createTtlIndex(collections.rotatedSessionAliases),
|
|
139
|
+
collections.rotatedSessionAliases.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" }),
|
|
140
|
+
collections.sessions.createIndex(
|
|
141
|
+
{ subject: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
142
|
+
{ name: "subject_scope_idx" }
|
|
143
|
+
),
|
|
144
|
+
collections.sessions.createIndex(
|
|
145
|
+
{ providerSessionId: 1, "provider.issuer": 1, "provider.clientId": 1 },
|
|
146
|
+
{ name: "provider_session_scope_idx" }
|
|
147
|
+
),
|
|
148
|
+
collections.sessions.createIndex({ logicalSessionId: 1 }, { name: "logical_session_idx" })
|
|
149
|
+
]);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/store.ts
|
|
68
153
|
var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
|
|
69
154
|
var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
|
|
70
|
-
var
|
|
155
|
+
var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
|
|
156
|
+
var isDuplicateKeyError = (error) => typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
71
157
|
var MongoOidcVaultStore = class {
|
|
72
158
|
constructor(options) {
|
|
159
|
+
const collectionNames = resolveCollectionNames(options);
|
|
73
160
|
this.db = options.db;
|
|
74
161
|
this.authorizationTransactions = options.db.collection(
|
|
75
|
-
|
|
162
|
+
collectionNames.authorizationTransactions
|
|
76
163
|
);
|
|
77
|
-
this.exchangeCodes = options.db.collection(
|
|
78
|
-
|
|
164
|
+
this.exchangeCodes = options.db.collection(collectionNames.exchangeCodes);
|
|
165
|
+
this.sessions = options.db.collection(collectionNames.sessions);
|
|
166
|
+
this.backchannelLogoutTokenJtis = options.db.collection(
|
|
167
|
+
collectionNames.backchannelLogoutTokenJtis
|
|
79
168
|
);
|
|
80
|
-
this.
|
|
81
|
-
|
|
169
|
+
this.rotatedSessionAliases = options.db.collection(
|
|
170
|
+
collectionNames.rotatedSessionAliases
|
|
82
171
|
);
|
|
172
|
+
this.rotatedSessionAliasRetentionMs = options.rotatedSessionAliasRetentionMs ?? DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS;
|
|
173
|
+
if (!Number.isFinite(this.rotatedSessionAliasRetentionMs) || this.rotatedSessionAliasRetentionMs <= 0) {
|
|
174
|
+
throw new TypeError("OIDC vault rotated session alias retention must be a finite positive duration.");
|
|
175
|
+
}
|
|
83
176
|
this.now = options.now ?? (() => Date.now());
|
|
84
|
-
this.
|
|
85
|
-
|
|
177
|
+
this.initialization = this.initialize().then(void 0, (error) => {
|
|
178
|
+
this.initializationError = error;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async ready() {
|
|
182
|
+
await this.waitUntilReady();
|
|
86
183
|
}
|
|
87
184
|
async createAuthorizationTransaction(input) {
|
|
88
|
-
await this.
|
|
185
|
+
await this.waitUntilReady();
|
|
89
186
|
await this.authorizationTransactions.replaceOne({ _id: input.state }, authorizationTransactionToDocument(input), {
|
|
90
187
|
upsert: true
|
|
91
188
|
});
|
|
92
189
|
}
|
|
93
190
|
async consumeAuthorizationTransaction(state) {
|
|
94
|
-
await this.
|
|
191
|
+
await this.waitUntilReady();
|
|
95
192
|
const record = await this.authorizationTransactions.findOneAndDelete({ _id: state });
|
|
96
|
-
if (!record ||
|
|
193
|
+
if (!record || isExpired(record, this.now())) {
|
|
97
194
|
return null;
|
|
98
195
|
}
|
|
99
196
|
return authorizationDocumentToRecord(record);
|
|
100
197
|
}
|
|
101
198
|
async createExchangeCode(input) {
|
|
102
|
-
await this.
|
|
199
|
+
await this.waitUntilReady();
|
|
103
200
|
await this.exchangeCodes.replaceOne({ _id: input.code }, exchangeCodeToDocument(input), { upsert: true });
|
|
104
201
|
}
|
|
105
202
|
async consumeExchangeCode(code) {
|
|
106
|
-
await this.
|
|
203
|
+
await this.waitUntilReady();
|
|
107
204
|
const record = await this.exchangeCodes.findOneAndDelete({ _id: code });
|
|
108
|
-
if (!record ||
|
|
205
|
+
if (!record || isExpired(record, this.now())) {
|
|
109
206
|
return null;
|
|
110
207
|
}
|
|
111
208
|
return exchangeDocumentToRecord(record);
|
|
112
209
|
}
|
|
113
210
|
async createSession(input) {
|
|
114
|
-
await this.
|
|
211
|
+
await this.waitUntilReady();
|
|
115
212
|
const timestamp = this.now();
|
|
116
213
|
const session = {
|
|
117
214
|
...input,
|
|
215
|
+
logicalSessionId: input.logicalSessionId ?? input.sessionId,
|
|
118
216
|
createdAt: input.createdAt ?? timestamp,
|
|
119
217
|
updatedAt: input.updatedAt ?? timestamp
|
|
120
218
|
};
|
|
@@ -122,7 +220,7 @@ var MongoOidcVaultStore = class {
|
|
|
122
220
|
return session;
|
|
123
221
|
}
|
|
124
222
|
async getSession(sessionId) {
|
|
125
|
-
await this.
|
|
223
|
+
await this.waitUntilReady();
|
|
126
224
|
const session = await this.sessions.findOne({ _id: sessionId });
|
|
127
225
|
if (!session || await this.isExpiredAndCleanup(this.sessions, session)) {
|
|
128
226
|
return null;
|
|
@@ -130,42 +228,81 @@ var MongoOidcVaultStore = class {
|
|
|
130
228
|
return documentToSession(session);
|
|
131
229
|
}
|
|
132
230
|
async rotateSession(input) {
|
|
133
|
-
await this.
|
|
134
|
-
if (
|
|
135
|
-
|
|
231
|
+
await this.waitUntilReady();
|
|
232
|
+
if (input.nextSession.sessionId === input.sessionId) {
|
|
233
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return await this.rotateSessionWithTransaction(input);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (isDuplicateKeyError(error)) {
|
|
239
|
+
throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
136
242
|
}
|
|
137
|
-
return this.rotateSessionWithoutTransaction(input);
|
|
138
243
|
}
|
|
139
|
-
async
|
|
140
|
-
await this.
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
244
|
+
async deleteSession(sessionId) {
|
|
245
|
+
await this.waitUntilReady();
|
|
246
|
+
const session = await this.sessions.findOne({ _id: sessionId });
|
|
247
|
+
if (session) {
|
|
248
|
+
const logicalSessionId = session.logicalSessionId ?? session._id;
|
|
249
|
+
const result = await this.sessions.deleteOne({ _id: sessionId });
|
|
250
|
+
if (result.deletedCount === 1) {
|
|
251
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const alias2 = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
255
|
+
if (alias2 && !isExpired(alias2, this.now())) {
|
|
256
|
+
await this.deleteSessionsByLogicalSessionId(alias2.logicalSessionId);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const alias = await this.rotatedSessionAliases.findOne({ _id: sessionId });
|
|
261
|
+
if (alias && !isExpired(alias, this.now())) {
|
|
262
|
+
await this.deleteSessionsByLogicalSessionId(alias.logicalSessionId);
|
|
145
263
|
}
|
|
146
|
-
return input.nextSession;
|
|
147
264
|
}
|
|
148
|
-
async
|
|
149
|
-
|
|
265
|
+
async deleteSessionsByLogicalSessionId(input) {
|
|
266
|
+
await this.waitUntilReady();
|
|
267
|
+
const resolved = toLogicalSessionDeleteInput(input);
|
|
268
|
+
const result = await this.sessions.deleteMany({ logicalSessionId: resolved.logicalSessionId });
|
|
269
|
+
await this.deleteRotatedSessionAliasesByLogicalSessionId(resolved.logicalSessionId);
|
|
270
|
+
return result.deletedCount;
|
|
271
|
+
}
|
|
272
|
+
async consumeBackchannelLogoutTokenJti(input) {
|
|
273
|
+
await this.waitUntilReady();
|
|
274
|
+
const now = this.now();
|
|
275
|
+
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const replacedExpired = await this.backchannelLogoutTokenJtis.findOneAndUpdate(
|
|
279
|
+
{
|
|
280
|
+
_id: input.jti,
|
|
281
|
+
expiresAt: { $lte: new Date(now) }
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
$set: { expiresAt: new Date(input.expiresAt) }
|
|
285
|
+
},
|
|
286
|
+
{ returnDocument: "after" }
|
|
287
|
+
);
|
|
288
|
+
if (replacedExpired) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
150
291
|
try {
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (deleteResult.deletedCount !== 1) {
|
|
155
|
-
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
156
|
-
}
|
|
292
|
+
await this.backchannelLogoutTokenJtis.insertOne({
|
|
293
|
+
_id: input.jti,
|
|
294
|
+
expiresAt: new Date(input.expiresAt)
|
|
157
295
|
});
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
296
|
+
return true;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (isDuplicateKeyError(error)) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
throw error;
|
|
161
302
|
}
|
|
162
303
|
}
|
|
163
|
-
async deleteSession(sessionId) {
|
|
164
|
-
await this.ready;
|
|
165
|
-
await this.sessions.deleteOne({ _id: sessionId });
|
|
166
|
-
}
|
|
167
304
|
async deleteSessionsBySubject(input) {
|
|
168
|
-
await this.
|
|
305
|
+
await this.waitUntilReady();
|
|
169
306
|
const resolved = toSubjectDeleteInput(input);
|
|
170
307
|
const filter = { subject: resolved.subject };
|
|
171
308
|
if (resolved.issuer !== void 0) {
|
|
@@ -174,11 +311,11 @@ var MongoOidcVaultStore = class {
|
|
|
174
311
|
if (resolved.clientId !== void 0) {
|
|
175
312
|
filter["provider.clientId"] = resolved.clientId;
|
|
176
313
|
}
|
|
177
|
-
const result = await this.
|
|
314
|
+
const result = await this.deleteSessionsAndAliasesByFilter(filter);
|
|
178
315
|
return result.deletedCount;
|
|
179
316
|
}
|
|
180
317
|
async deleteSessionsByProviderSessionId(input) {
|
|
181
|
-
await this.
|
|
318
|
+
await this.waitUntilReady();
|
|
182
319
|
const resolved = toProviderSessionDeleteInput(input);
|
|
183
320
|
const filter = { providerSessionId: resolved.providerSessionId };
|
|
184
321
|
if (resolved.issuer !== void 0) {
|
|
@@ -187,46 +324,107 @@ var MongoOidcVaultStore = class {
|
|
|
187
324
|
if (resolved.clientId !== void 0) {
|
|
188
325
|
filter["provider.clientId"] = resolved.clientId;
|
|
189
326
|
}
|
|
190
|
-
const result = await this.
|
|
327
|
+
const result = await this.deleteSessionsAndAliasesByFilter(filter);
|
|
191
328
|
return result.deletedCount;
|
|
192
329
|
}
|
|
193
|
-
async
|
|
330
|
+
async initialize() {
|
|
194
331
|
await Promise.all([
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
332
|
+
ensureStoreIndexes({
|
|
333
|
+
authorizationTransactions: this.authorizationTransactions,
|
|
334
|
+
exchangeCodes: this.exchangeCodes,
|
|
335
|
+
sessions: this.sessions,
|
|
336
|
+
backchannelLogoutTokenJtis: this.backchannelLogoutTokenJtis,
|
|
337
|
+
rotatedSessionAliases: this.rotatedSessionAliases
|
|
338
|
+
}),
|
|
339
|
+
assertTransactionSupport(this.db)
|
|
200
340
|
]);
|
|
201
341
|
}
|
|
202
|
-
async
|
|
342
|
+
async waitUntilReady() {
|
|
343
|
+
await this.initialization;
|
|
344
|
+
if (this.initializationError !== void 0) {
|
|
345
|
+
throw this.initializationError;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async rotateSessionWithTransaction(input) {
|
|
349
|
+
const session = this.db.client.startSession();
|
|
350
|
+
const nextSession = await this.normalizeRotatedSession(input);
|
|
203
351
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
352
|
+
await session.withTransaction(async () => {
|
|
353
|
+
await this.sessions.insertOne(sessionToDocument(nextSession), { session });
|
|
354
|
+
const deleteResult = await this.sessions.deleteOne({ _id: input.sessionId }, { session });
|
|
355
|
+
if (deleteResult.deletedCount !== 1) {
|
|
356
|
+
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
357
|
+
}
|
|
358
|
+
await this.createRotatedSessionAlias(input.sessionId, nextSession, session);
|
|
359
|
+
});
|
|
360
|
+
return nextSession;
|
|
361
|
+
} finally {
|
|
362
|
+
await session.endSession();
|
|
208
363
|
}
|
|
209
364
|
}
|
|
210
|
-
async
|
|
211
|
-
|
|
212
|
-
|
|
365
|
+
async createRotatedSessionAlias(previousSessionId, nextSession, session) {
|
|
366
|
+
const logicalSessionId = nextSession.logicalSessionId ?? nextSession.sessionId;
|
|
367
|
+
const now = this.now();
|
|
368
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId, expiresAt: { $lte: new Date(now) } }, { session });
|
|
369
|
+
await this.rotatedSessionAliases.updateOne(
|
|
370
|
+
{ _id: previousSessionId },
|
|
213
371
|
{
|
|
214
|
-
|
|
215
|
-
|
|
372
|
+
$set: {
|
|
373
|
+
logicalSessionId,
|
|
374
|
+
expiresAt: this.getRotatedSessionAliasExpiresAt(nextSession, now)
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
{ upsert: true, session }
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
async normalizeRotatedSession(input) {
|
|
381
|
+
const previous = await this.getSession(input.sessionId);
|
|
382
|
+
if (!previous) {
|
|
383
|
+
throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
...input.nextSession,
|
|
387
|
+
logicalSessionId: input.nextSession.logicalSessionId ?? previous.logicalSessionId ?? input.sessionId
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async deleteSessionsAndAliasesByFilter(filter) {
|
|
391
|
+
let deletedCount = 0;
|
|
392
|
+
for (; ; ) {
|
|
393
|
+
const sessions = await this.sessions.find(filter).project({
|
|
394
|
+
_id: 1,
|
|
395
|
+
logicalSessionId: 1
|
|
396
|
+
}).toArray();
|
|
397
|
+
const logicalSessionIds = [...new Set(sessions.map((session) => session.logicalSessionId ?? session._id))];
|
|
398
|
+
if (logicalSessionIds.length === 0) {
|
|
399
|
+
return { deletedCount };
|
|
216
400
|
}
|
|
401
|
+
const result = await this.sessions.deleteMany(filter);
|
|
402
|
+
deletedCount += result.deletedCount;
|
|
403
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId: { $in: logicalSessionIds } });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
getRotatedSessionAliasExpiresAt(nextSession, now) {
|
|
407
|
+
return new Date(
|
|
408
|
+
typeof nextSession.expiresAt === "number" ? nextSession.expiresAt : now + this.rotatedSessionAliasRetentionMs
|
|
217
409
|
);
|
|
218
410
|
}
|
|
411
|
+
async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
|
|
412
|
+
await this.rotatedSessionAliases.deleteMany({ logicalSessionId });
|
|
413
|
+
}
|
|
219
414
|
async isExpiredAndCleanup(collection, record) {
|
|
220
415
|
if (!isExpired(record, this.now())) {
|
|
221
416
|
return false;
|
|
222
417
|
}
|
|
223
|
-
await collection.deleteOne({ _id: record._id });
|
|
418
|
+
await collection.deleteOne({ _id: record._id, expiresAt: record.expiresAt });
|
|
224
419
|
return true;
|
|
225
420
|
}
|
|
226
421
|
};
|
|
422
|
+
|
|
423
|
+
// src/index.ts
|
|
227
424
|
function createMongoOidcVaultStore(options) {
|
|
228
425
|
return new MongoOidcVaultStore(options);
|
|
229
426
|
}
|
|
230
427
|
export {
|
|
428
|
+
DEFAULT_ROTATED_SESSION_ALIAS_RETENTION_MS,
|
|
231
429
|
createMongoOidcVaultStore
|
|
232
430
|
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/express-oidc-vault-mongodb-store",
|
|
3
3
|
"description": "MongoDB store provider for @web-ts-toolkit/express-oidc-vault",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-mongodb-store",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.33.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"express",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=
|
|
25
|
+
"node": ">=22"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@web-ts-toolkit/express-oidc-vault": "0.
|
|
28
|
+
"@web-ts-toolkit/express-oidc-vault": "0.33.0",
|
|
29
29
|
"mongodb": "^6.20.0"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|