Skip to content

Commit 033cd7b

Browse files
committed
feat(oidc-mock-provider,mongodb-runner): make OIDC mocks more broadly usable COMPASS-10034
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 3da8b0e commit 033cd7b

File tree

12 files changed

+441
-109
lines changed

12 files changed

+441
-109
lines changed

packages/mongodb-runner/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Helper for spinning up MongoDB servers and clusters for testing.
1111
$ npx mongodb-runner start -t sharded
1212
$ npx mongodb-runner start -t replset -- --port 27017
1313
$ npx mongodb-runner start -t replset -- --setParameter allowDiskUseByDefault=true
14+
$ npx mongodb-runner start -t replset --version 8.2.x-enterprise --oidc='--payload={"groups":["x"],"sub":"y","aud":"aud"} --expiry=60 --skip-refresh-token'
1415
$ npx mongodb-runner stop --all
1516
$ npx mongodb-runner exec -t standalone -- sh -c 'mongosh $MONGODB_URI'
1617
$ 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: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
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';
107

118
(async function () {
@@ -71,6 +68,10 @@ import * as utilities from './index';
7168
type: 'boolean',
7269
describe: 'for `stop`: stop all clusters',
7370
})
71+
.option('oidc', {
72+
type: 'string',
73+
describe: 'Configure OIDC authentication on the server',
74+
})
7475
.option('debug', { type: 'boolean', describe: 'Enable debug output' })
7576
.command('start', 'Start a MongoDB instance')
7677
.command('stop', 'Stop a MongoDB instance')
@@ -87,9 +88,23 @@ import * as utilities from './index';
8788
createDebug.enable('mongodb-runner');
8889
}
8990

91+
if (argv.oidc && process.platform !== 'linux') {
92+
console.warn(
93+
'OIDC authentication is currently only supported on Linux platforms.',
94+
);
95+
}
96+
if (argv.oidc && !argv.version?.includes('enterprise')) {
97+
console.warn(
98+
'OIDC authentication is currently only supported on Enterprise server versions.',
99+
);
100+
}
101+
90102
async function start() {
91103
const { cluster, id } = await utilities.start(argv, args);
92104
console.log(`Server started and running at ${cluster.connectionString}`);
105+
if (cluster.oidcIssuer) {
106+
console.log(`OIDC provider started and running at ${cluster.oidcIssuer}`);
107+
}
93108
console.log('Run the following command to stop the instance:');
94109
console.log(
95110
`${argv.$0} stop --id=${id}` +
@@ -118,37 +133,7 @@ import * as utilities from './index';
118133
}
119134

120135
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-
}
136+
await utilities.exec(argv, args);
152137
}
153138

154139
// 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: 39 additions & 1 deletion
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

@@ -80,6 +87,10 @@ export class MongoCluster {
8087
}`;
8188
}
8289

90+
get oidcIssuer(): string | undefined {
91+
return this.oidcMockProviderProcess?.issuer;
92+
}
93+
8394
get connectionStringUrl(): ConnectionString {
8495
return new ConnectionString(this.connectionString);
8596
}
@@ -105,6 +116,31 @@ export class MongoCluster {
105116
);
106117
}
107118

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

234270
async close(): Promise<void> {
235271
await Promise.all(
236-
[...this.servers, ...this.shards].map((closable) => closable.close()),
272+
[...this.servers, ...this.shards, this.oidcMockProviderProcess].map(
273+
(closable) => closable?.close(),
274+
),
237275
);
238276
this.servers = [];
239277
this.shards = [];
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
}
27+
},
28+
});
29+
debug('started OIDC mock provider with UUID', {
30+
issuer: provider.issuer,
31+
uuid,
32+
audience,
33+
});
34+
process.send?.({
35+
issuer: provider.issuer,
36+
uuid,
37+
audience,
38+
});
39+
})().catch((error) => {
40+
// eslint-disable-next-line no-console
41+
console.error('Error starting OIDC mock identity provider server:', error);
42+
process.exitCode = 1;
43+
});
44+
}
45+
46+
export class OIDCMockProviderProcess {
47+
pid?: number;
48+
issuer?: string;
49+
uuid?: string;
50+
audience?: string;
51+
52+
serialize(): unknown /* JSON-serializable */ {
53+
return {
54+
pid: this.pid,
55+
issuer: this.issuer,
56+
uuid: this.uuid,
57+
audience: this.audience,
58+
};
59+
}
60+
61+
static deserialize(serialized: any): OIDCMockProviderProcess {
62+
const process = new OIDCMockProviderProcess();
63+
process.pid = serialized.pid;
64+
process.issuer = serialized.issuer;
65+
process.uuid = serialized.uuid;
66+
process.audience = serialized.audience;
67+
return process;
68+
}
69+
70+
private constructor() {
71+
/* see .start() */
72+
}
73+
74+
static async start(args: string): Promise<OIDCMockProviderProcess> {
75+
const oidcProc = new this();
76+
debug('spawning OIDC child process', [process.execPath, __filename], args);
77+
const proc = spawn(process.execPath, [__filename], {
78+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
79+
env: {
80+
...process.env,
81+
RUN_OIDC_MOCK_PROVIDER: args,
82+
},
83+
detached: true,
84+
serialization: 'advanced',
85+
});
86+
await once(proc, 'spawn');
87+
try {
88+
oidcProc.pid = proc.pid;
89+
const [msg] = await Promise.race([
90+
once(proc, 'message'),
91+
once(proc, 'exit').then(() => {
92+
throw new Error(
93+
`OIDC mock provider process exited before sending message (${String(proc.exitCode)}, ${String(proc.signalCode)})`,
94+
);
95+
}),
96+
]);
97+
debug('received message from OIDC child process', msg);
98+
oidcProc.issuer = msg.issuer;
99+
oidcProc.uuid = msg.uuid;
100+
oidcProc.audience = msg.audience;
101+
} catch (err) {
102+
proc.kill();
103+
throw err;
104+
}
105+
proc.unref();
106+
proc.channel?.unref();
107+
debug('OIDC setup complete, uuid =', oidcProc.uuid);
108+
return oidcProc;
109+
}
110+
111+
async close(): Promise<void> {
112+
try {
113+
if (this.pid) process.kill(this.pid, 0);
114+
} catch (e) {
115+
if (typeof e === 'object' && e && 'code' in e && e.code === 'ESRCH')
116+
return; // process already exited
117+
}
118+
119+
if (!this.issuer || !this.uuid) return;
120+
await fetch(new URL(this.issuer, `/shutdown/${this.uuid}`));
121+
this.uuid = undefined;
122+
}
123+
}

packages/mongodb-runner/src/runner-helpers.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { MongoClusterOptions } from './mongocluster';
44
import { MongoCluster } from './mongocluster';
55
import { parallelForEach } from './util';
66
import * as fs from 'fs/promises';
7+
import { spawn } from 'child_process';
8+
import { once } from 'events';
79

810
interface StoredInstance {
911
id: string;
@@ -90,3 +92,43 @@ export async function stop(argv: {
9092
await fs.rm(instance.filepath);
9193
});
9294
}
95+
96+
export async function exec(
97+
argv: {
98+
id?: string;
99+
runnerDir: string;
100+
} & MongoClusterOptions,
101+
args: string[],
102+
) {
103+
let mongodArgs: string[];
104+
let execArgs: string[];
105+
106+
const doubleDashIndex = args.indexOf('--');
107+
if (doubleDashIndex !== -1) {
108+
mongodArgs = args.slice(0, doubleDashIndex);
109+
execArgs = args.slice(doubleDashIndex + 1);
110+
} else {
111+
mongodArgs = [];
112+
execArgs = args;
113+
}
114+
const cluster = await MongoCluster.start({
115+
...argv,
116+
args: mongodArgs,
117+
});
118+
try {
119+
const [prog, ...progArgs] = execArgs;
120+
const child = spawn(prog, progArgs, {
121+
stdio: 'inherit',
122+
env: {
123+
...process.env,
124+
// both spellings since otherwise I'd end up misspelling these half of the time
125+
MONGODB_URI: cluster.connectionString,
126+
MONGODB_URL: cluster.connectionString,
127+
MONGODB_HOSTPORT: cluster.hostport,
128+
},
129+
});
130+
[process.exitCode] = await once(child, 'exit');
131+
} finally {
132+
await cluster.close();
133+
}
134+
}

0 commit comments

Comments
 (0)