Skip to content

Commit 8a9d630

Browse files
authored
feat(oidc-mock-provider,mongodb-runner): make OIDC mocks more broadly usable COMPASS-10034 (#589)
Internal TSEs have requested making oidc-mock-provider available for internal testing with OIDC. While it cannot replicate every aspect of real-world identity providers, it is an easily spun up local equivalent of those, and provides flexibility that those real-world identity providers lack in terms of configurability. This change widens the array of CLI options provided for the oidc-mock-provider CLI, and integrates it into mongodb-runner so that the latter can spin up a joint OIDC-IdP-and-mongod-cluster environment on Linux, if that is desired.
1 parent 94ca629 commit 8a9d630

File tree

12 files changed

+470
-113
lines changed

12 files changed

+470
-113
lines changed

packages/mongodb-runner/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Helper for spinning up MongoDB servers and clusters for testing.
44

5+
## Requirements
6+
7+
Node.js >= 20.19.5, npm >= 11.6.0. Running as `npx mongodb-runner ...`
8+
is typically the easiest way to install/run this tool.
9+
510
## Example usage
611

712
> Note: Version 5 of mongodb-runner is a full re-write. Many things work
@@ -11,6 +16,7 @@ Helper for spinning up MongoDB servers and clusters for testing.
1116
$ npx mongodb-runner start -t sharded
1217
$ npx mongodb-runner start -t replset -- --port 27017
1318
$ npx mongodb-runner start -t replset -- --setParameter allowDiskUseByDefault=true
19+
$ npx mongodb-runner start -t replset --version 8.2.x-enterprise --oidc='--payload={"groups":["x"],"sub":"y","aud":"aud"} --expiry=60 --skip-refresh-token'
1420
$ npx mongodb-runner stop --all
1521
$ npx mongodb-runner exec -t standalone -- sh -c 'mongosh $MONGODB_URI'
1622
$ npx mongodb-runner exec -t standalone -- --setParameter allowDiskUseByDefault=true -- sh -c 'mongosh $MONGODB_URI'

packages/mongodb-runner/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@
5757
},
5858
"dependencies": {
5959
"@mongodb-js/mongodb-downloader": "^1.0.0",
60+
"@mongodb-js/oidc-mock-provider": "^0.11.5",
61+
"@mongodb-js/saslprep": "^1.3.2",
6062
"debug": "^4.4.0",
6163
"mongodb": "^6.9.0",
62-
"@mongodb-js/saslprep": "^1.3.2",
6364
"mongodb-connection-string-url": "^3.0.0",
6465
"yargs": "^17.7.2"
6566
},

packages/mongodb-runner/src/cli.ts

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
/* eslint-disable no-console */
22
import yargs from 'yargs';
3-
import { MongoCluster } from './mongocluster';
43
import os from 'os';
54
import path from 'path';
6-
import { spawn } from 'child_process';
75
import createDebug from 'debug';
8-
import { once } from 'events';
96
import * as utilities from './index';
7+
import { ConnectionString } from 'mongodb-connection-string-url';
8+
import type { MongoClientOptions } from 'mongodb';
109

1110
(async function () {
1211
const defaultRunnerDir = path.join(os.homedir(), '.mongodb', 'runner2');
@@ -71,6 +70,10 @@ import * as utilities from './index';
7170
type: 'boolean',
7271
describe: 'for `stop`: stop all clusters',
7372
})
73+
.option('oidc', {
74+
type: 'string',
75+
describe: 'Configure OIDC authentication on the server',
76+
})
7477
.option('debug', { type: 'boolean', describe: 'Enable debug output' })
7578
.command('start', 'Start a MongoDB instance')
7679
.command('stop', 'Stop a MongoDB instance')
@@ -87,9 +90,29 @@ import * as utilities from './index';
8790
createDebug.enable('mongodb-runner');
8891
}
8992

93+
if (argv.oidc && process.platform !== 'linux') {
94+
console.warn(
95+
'OIDC authentication is currently only supported on Linux platforms.',
96+
);
97+
}
98+
if (argv.oidc && !argv.version?.includes('enterprise')) {
99+
console.warn(
100+
'OIDC authentication is currently only supported on Enterprise server versions.',
101+
);
102+
}
103+
90104
async function start() {
91105
const { cluster, id } = await utilities.start(argv, args);
92-
console.log(`Server started and running at ${cluster.connectionString}`);
106+
const cs = new ConnectionString(cluster.connectionString);
107+
console.log(`Server started and running at ${cs.toString()}`);
108+
if (cluster.oidcIssuer) {
109+
cs.typedSearchParams<MongoClientOptions>().set(
110+
'authMechanism',
111+
'MONGODB-OIDC',
112+
);
113+
console.log(`OIDC provider started and running at ${cluster.oidcIssuer}`);
114+
console.log(`Server connection string with OIDC auth: ${cs.toString()}`);
115+
}
93116
console.log('Run the following command to stop the instance:');
94117
console.log(
95118
`${argv.$0} stop --id=${id}` +
@@ -118,37 +141,7 @@ import * as utilities from './index';
118141
}
119142

120143
async function exec() {
121-
let mongodArgs: string[];
122-
let execArgs: string[];
123-
124-
const doubleDashIndex = args.indexOf('--');
125-
if (doubleDashIndex !== -1) {
126-
mongodArgs = args.slice(0, doubleDashIndex);
127-
execArgs = args.slice(doubleDashIndex + 1);
128-
} else {
129-
mongodArgs = [];
130-
execArgs = args;
131-
}
132-
const cluster = await MongoCluster.start({
133-
...argv,
134-
args: mongodArgs,
135-
});
136-
try {
137-
const [prog, ...progArgs] = execArgs;
138-
const child = spawn(prog, progArgs, {
139-
stdio: 'inherit',
140-
env: {
141-
...process.env,
142-
// both spellings since otherwise I'd end up misspelling these half of the time
143-
MONGODB_URI: cluster.connectionString,
144-
MONGODB_URL: cluster.connectionString,
145-
MONGODB_HOSTPORT: cluster.hostport,
146-
},
147-
});
148-
[process.exitCode] = await once(child, 'exit');
149-
} finally {
150-
await cluster.close();
151-
}
144+
await utilities.exec(argv, args);
152145
}
153146

154147
// eslint-disable-next-line @typescript-eslint/require-await

packages/mongodb-runner/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export { MongoServer, MongoServerOptions } from './mongoserver';
22

33
export { MongoCluster, MongoClusterOptions } from './mongocluster';
44
export type { ConnectionString } from 'mongodb-connection-string-url';
5-
export { prune, start, stop, instances } from './runner-helpers';
5+
export { prune, start, stop, exec, instances } from './runner-helpers';

packages/mongodb-runner/src/mongocluster.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { downloadMongoDb } from '@mongodb-js/mongodb-downloader';
66
import type { MongoClientOptions } from 'mongodb';
77
import { MongoClient } from 'mongodb';
88
import { sleep, range, uuid, debug } from './util';
9+
import { OIDCMockProviderProcess } from './oidc';
910

1011
export interface MongoClusterOptions
1112
extends Pick<
@@ -19,13 +20,15 @@ export interface MongoClusterOptions
1920
version?: string;
2021
downloadDir?: string;
2122
downloadOptions?: DownloadOptions;
23+
oidc?: string;
2224
}
2325

2426
export class MongoCluster {
2527
private topology: MongoClusterOptions['topology'] = 'standalone';
2628
private replSetName?: string;
2729
private servers: MongoServer[] = []; // mongod/mongos
2830
private shards: MongoCluster[] = []; // replsets
31+
private oidcMockProviderProcess?: OIDCMockProviderProcess;
2932

3033
private constructor() {
3134
/* see .start() */
@@ -50,6 +53,7 @@ export class MongoCluster {
5053
replSetName: this.replSetName,
5154
servers: this.servers.map((srv) => srv.serialize()),
5255
shards: this.shards.map((shard) => shard.serialize()),
56+
oidcMockProviderProcess: this.oidcMockProviderProcess?.serialize(),
5357
};
5458
}
5559

@@ -67,6 +71,9 @@ export class MongoCluster {
6771
cluster.shards = await Promise.all(
6872
serialized.shards.map((shard: any) => MongoCluster.deserialize(shard)),
6973
);
74+
cluster.oidcMockProviderProcess = serialized.oidcMockProviderProcess
75+
? OIDCMockProviderProcess.deserialize(serialized.oidcMockProviderProcess)
76+
: undefined;
7077
return cluster;
7178
}
7279

@@ -75,9 +82,17 @@ export class MongoCluster {
7582
}
7683

7784
get connectionString(): string {
78-
return `mongodb://${this.hostport}/${
79-
this.replSetName ? `?replicaSet=${this.replSetName}` : ''
80-
}`;
85+
const cs = new ConnectionString(`mongodb://${this.hostport}/`);
86+
if (this.replSetName)
87+
cs.typedSearchParams<MongoClientOptions>().set(
88+
'replicaSet',
89+
this.replSetName,
90+
);
91+
return cs.toString();
92+
}
93+
94+
get oidcIssuer(): string | undefined {
95+
return this.oidcMockProviderProcess?.issuer;
8196
}
8297

8398
get connectionStringUrl(): ConnectionString {
@@ -105,6 +120,31 @@ export class MongoCluster {
105120
);
106121
}
107122

123+
if (options.oidc !== undefined) {
124+
cluster.oidcMockProviderProcess = await OIDCMockProviderProcess.start(
125+
options.oidc || '--port=0',
126+
);
127+
const oidcServerConfig = [
128+
{
129+
issuer: cluster.oidcMockProviderProcess.issuer,
130+
audience: cluster.oidcMockProviderProcess.audience,
131+
authNamePrefix: 'dev',
132+
clientId: 'cid',
133+
authorizationClaim: 'groups',
134+
},
135+
];
136+
delete options.oidc;
137+
options.args = [
138+
...(options.args ?? []),
139+
'--setParameter',
140+
`oidcIdentityProviders=${JSON.stringify(oidcServerConfig)}`,
141+
'--setParameter',
142+
'authenticationMechanisms=SCRAM-SHA-256,MONGODB-OIDC',
143+
'--setParameter',
144+
'enableTestCommands=true',
145+
];
146+
}
147+
108148
if (options.topology === 'standalone') {
109149
cluster.servers.push(
110150
await MongoServer.start({
@@ -233,7 +273,9 @@ export class MongoCluster {
233273

234274
async close(): Promise<void> {
235275
await Promise.all(
236-
[...this.servers, ...this.shards].map((closable) => closable.close()),
276+
[...this.servers, ...this.shards, this.oidcMockProviderProcess].map(
277+
(closable) => closable?.close(),
278+
),
237279
);
238280
this.servers = [];
239281
this.shards = [];
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { spawn } from 'child_process';
2+
import { once } from 'events';
3+
import { parseCLIArgs, OIDCMockProvider } from '@mongodb-js/oidc-mock-provider';
4+
import { debug } from './util';
5+
6+
if (process.env.RUN_OIDC_MOCK_PROVIDER !== undefined) {
7+
(async function main() {
8+
const uuid = crypto.randomUUID();
9+
debug('starting OIDC mock provider with UUID', uuid);
10+
const config = parseCLIArgs(process.env.RUN_OIDC_MOCK_PROVIDER);
11+
const sampleTokenConfig = await config.getTokenPayload({
12+
client_id: 'cid',
13+
scope: 'scope',
14+
});
15+
debug('sample OIDC token config', sampleTokenConfig, uuid);
16+
const audience = sampleTokenConfig.payload.aud;
17+
const provider = await OIDCMockProvider.create({
18+
...config,
19+
overrideRequestHandler(url, req, res) {
20+
if (new URL(url).pathname === `/shutdown/${uuid}`) {
21+
res.on('close', () => {
22+
process.exit();
23+
});
24+
res.writeHead(200);
25+
res.end();
26+
return;
27+
}
28+
return config.overrideRequestHandler?.(url, req, res);
29+
},
30+
});
31+
debug('started OIDC mock provider with UUID', {
32+
issuer: provider.issuer,
33+
uuid,
34+
audience,
35+
});
36+
process.send?.({
37+
issuer: provider.issuer,
38+
uuid,
39+
audience,
40+
});
41+
})().catch((error) => {
42+
// eslint-disable-next-line no-console
43+
console.error('Error starting OIDC mock identity provider server:', error);
44+
process.exitCode = 1;
45+
});
46+
}
47+
48+
export class OIDCMockProviderProcess {
49+
pid?: number;
50+
issuer?: string;
51+
uuid?: string;
52+
audience?: string;
53+
54+
serialize(): unknown /* JSON-serializable */ {
55+
return {
56+
pid: this.pid,
57+
issuer: this.issuer,
58+
uuid: this.uuid,
59+
audience: this.audience,
60+
};
61+
}
62+
63+
static deserialize(serialized: any): OIDCMockProviderProcess {
64+
const process = new OIDCMockProviderProcess();
65+
process.pid = serialized.pid;
66+
process.issuer = serialized.issuer;
67+
process.uuid = serialized.uuid;
68+
process.audience = serialized.audience;
69+
return process;
70+
}
71+
72+
private constructor() {
73+
/* see .start() */
74+
}
75+
76+
static async start(args: string): Promise<OIDCMockProviderProcess> {
77+
const oidcProc = new this();
78+
debug('spawning OIDC child process', [process.execPath, __filename], args);
79+
const proc = spawn(process.execPath, [__filename], {
80+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
81+
env: {
82+
...process.env,
83+
RUN_OIDC_MOCK_PROVIDER: args,
84+
},
85+
detached: true,
86+
serialization: 'advanced',
87+
});
88+
await once(proc, 'spawn');
89+
try {
90+
oidcProc.pid = proc.pid;
91+
const [msg] = await Promise.race([
92+
once(proc, 'message'),
93+
once(proc, 'exit').then(() => {
94+
throw new Error(
95+
`OIDC mock provider process exited before sending message (${String(proc.exitCode)}, ${String(proc.signalCode)})`,
96+
);
97+
}),
98+
]);
99+
debug('received message from OIDC child process', msg);
100+
oidcProc.issuer = msg.issuer;
101+
oidcProc.uuid = msg.uuid;
102+
oidcProc.audience = msg.audience;
103+
} catch (err) {
104+
proc.kill();
105+
throw err;
106+
}
107+
proc.unref();
108+
proc.channel?.unref();
109+
debug('OIDC setup complete, uuid =', oidcProc.uuid);
110+
return oidcProc;
111+
}
112+
113+
async close(): Promise<void> {
114+
try {
115+
if (this.pid) process.kill(this.pid, 0);
116+
} catch (e) {
117+
if (typeof e === 'object' && e && 'code' in e && e.code === 'ESRCH')
118+
return; // process already exited
119+
}
120+
121+
if (!this.issuer || !this.uuid) return;
122+
await fetch(new URL(this.issuer, `/shutdown/${this.uuid}`));
123+
this.uuid = undefined;
124+
}
125+
}

0 commit comments

Comments
 (0)