Skip to content

Commit 943dd33

Browse files
authored
feat: improve diagnostics, allow custom HTTP options, account for IdP support for scopes (#189)
* chore: add token type to auth-succeeded log event * fix: improve error message when `Issuer.discover()` fails COMPASS-7605 * chore: disable last remaining eslint warning in tests * feat: do not request scopes if the IdP announces lack of support COMPASS-7437 * feat: track HTTP calls, allow custom HTTP options MONGOSH-1712
1 parent 24470e0 commit 943dd33

File tree

10 files changed

+359
-27
lines changed

10 files changed

+359
-27
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"@mongodb-js/eslint-config-devtools": "^0.9.9",
5858
"@mongodb-js/mocha-config-devtools": "^1.0.0",
5959
"@mongodb-js/monorepo-tools": "^1.1.4",
60-
"@mongodb-js/oidc-mock-provider": "^0.7.1",
60+
"@mongodb-js/oidc-mock-provider": "^0.8.0",
6161
"@mongodb-js/prettier-config-devtools": "^1.0.1",
6262
"@mongodb-js/tsconfig-devtools": "^1.0.0",
6363
"@types/chai": "^4.2.21",

src/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ import type {
1010
OIDCRequestFunction,
1111
TypedEventEmitter,
1212
} from './types';
13+
import type { RequestOptions } from 'https';
14+
15+
/** @public */
16+
export type HttpOptions = Partial<
17+
Pick<
18+
RequestOptions,
19+
| 'agent'
20+
| 'ca'
21+
| 'cert'
22+
| 'crl'
23+
| 'headers'
24+
| 'key'
25+
| 'lookup'
26+
| 'passphrase'
27+
| 'pfx'
28+
| 'timeout'
29+
>
30+
>;
1331

1432
/** @public */
1533
export type AuthFlowType = 'auth-code' | 'device-auth';
@@ -167,6 +185,13 @@ export interface MongoDBOIDCPluginOptions {
167185
* message being emitted but otherwise be ignored.
168186
*/
169187
throwOnIncompatibleSerializedState?: boolean;
188+
189+
/**
190+
* Provide custom HTTP options for individual HTTP calls.
191+
*/
192+
customHttpOptions?:
193+
| HttpOptions
194+
| ((url: string, options: Readonly<HttpOptions>) => HttpOptions);
170195
}
171196

172197
/** @public */

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
RedirectServerRequestHandler,
1010
RedirectServerRequestInfo,
1111
MongoDBOIDCPluginMongoClientOptions,
12+
HttpOptions,
1213
} from './api';
1314

1415
export type {

src/log-hook.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import type { MongoDBOIDCLogEventsMap, TypedEventEmitter } from './types';
22

33
/** @public */
44
export interface MongoLogWriter {
5+
debug?(
6+
c: string,
7+
id: unknown,
8+
ctx: string,
9+
msg: string,
10+
attr?: unknown,
11+
level?: 1 | 2 | 3 | 4 | 5
12+
): void;
513
info(c: string, id: unknown, ctx: string, msg: string, attr?: unknown): void;
614
warn(c: string, id: unknown, ctx: string, msg: string, attr?: unknown): void;
715
error(c: string, id: unknown, ctx: string, msg: string, attr?: unknown): void;
@@ -269,4 +277,36 @@ export function hookLoggerToMongoLogWriter(
269277
'Missing ID token in IdP response'
270278
);
271279
});
280+
281+
emitter.on('mongodb-oidc-plugin:outbound-http-request', (ev) => {
282+
log.debug?.(
283+
'OIDC-PLUGIN',
284+
mongoLogId(1_002_000_023),
285+
`${contextPrefix}-oidc`,
286+
'Outbound HTTP request',
287+
{ url: redactUrl(ev.url) }
288+
);
289+
});
290+
291+
emitter.on('mongodb-oidc-plugin:inbound-http-request', (ev) => {
292+
log.debug?.(
293+
'OIDC-PLUGIN',
294+
mongoLogId(1_002_000_024),
295+
`${contextPrefix}-oidc`,
296+
'Inbound HTTP request',
297+
{ url: redactUrl(ev.url) }
298+
);
299+
});
300+
}
301+
302+
function redactUrl(url: string): string {
303+
let parsed: URL;
304+
try {
305+
parsed = new URL(url);
306+
} catch {
307+
return '<Invalid URL>';
308+
}
309+
for (const key of [...parsed.searchParams.keys()])
310+
parsed.searchParams.set(key, '');
311+
return parsed.toString();
272312
}

src/plugin.spec.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
OIDCCallbackContext,
66
IdPServerInfo,
77
OIDCRequestFunction,
8+
OpenBrowserOptions,
89
} from './';
910
import { createMongoDBOIDCPlugin, hookLoggerToMongoLogWriter } from './';
1011
import { once } from 'events';
@@ -32,6 +33,24 @@ import { publicPluginToInternalPluginMap_DoNotUseOutsideOfTests } from './api';
3233
import type { Server as HTTPServer } from 'http';
3334
import { createServer as createHTTPServer } from 'http';
3435
import type { AddressInfo } from 'net';
36+
import type {
37+
OIDCMockProviderConfig,
38+
TokenMetadata,
39+
} from '@mongodb-js/oidc-mock-provider';
40+
import { OIDCMockProvider } from '@mongodb-js/oidc-mock-provider';
41+
42+
// node-fetch@3 is ESM-only...
43+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
44+
const fetch: typeof import('node-fetch').default = (...args) =>
45+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
46+
eval("import('node-fetch')").then((fetch: typeof import('node-fetch')) =>
47+
fetch.default(...args)
48+
);
49+
50+
// A 'browser' implementation that just does HTTP requests and ignores the response.
51+
async function fetchBrowser({ url }: OpenBrowserOptions): Promise<void> {
52+
(await fetch(url)).body?.resume();
53+
}
3554

3655
// Shorthand to avoid having to specify `principalName` and `abortSignal`
3756
// if they aren't being used in the first place.
@@ -308,6 +327,7 @@ describe('OIDC plugin (local OIDC provider)', function () {
308327
expect(serializedData.oidcPluginStateVersion).to.equal(0);
309328
expect(serializedData.state).to.have.lengthOf(1);
310329
expect(serializedData.state[0][0]).to.be.a('string');
330+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
311331
expect(Object.keys(serializedData.state[0][1]).sort()).to.deep.equal([
312332
'currentTokenSet',
313333
'lastIdTokenClaims',
@@ -827,6 +847,20 @@ describe('OIDC plugin (local OIDC provider)', function () {
827847
}
828848
});
829849

850+
it('includes a helpful error message when attempting to reach out to invalid issuer', async function () {
851+
try {
852+
await requestToken(plugin, {
853+
clientId: 'clientId',
854+
issuer: 'https://doesnotexist.mongodb.com/',
855+
});
856+
expect.fail('missed exception');
857+
} catch (err: any) {
858+
expect(err.message).to.include(
859+
'Unable to fetch issuer metadata for "https://doesnotexist.mongodb.com/":'
860+
);
861+
}
862+
});
863+
830864
context('with an issuer that reports custom metadata', function () {
831865
let server: HTTPServer;
832866
let response: Record<string, unknown>;
@@ -1014,3 +1048,137 @@ describe('OIDC plugin (local OIDC provider)', function () {
10141048
});
10151049
});
10161050
});
1051+
1052+
// eslint-disable-next-line mocha/max-top-level-suites
1053+
describe('OIDC plugin (mock OIDC provider)', function () {
1054+
let provider: OIDCMockProvider;
1055+
let getTokenPayload: OIDCMockProviderConfig['getTokenPayload'];
1056+
let additionalIssuerMetadata: OIDCMockProviderConfig['additionalIssuerMetadata'];
1057+
let receivedHttpRequests: string[] = [];
1058+
const tokenPayload = {
1059+
expires_in: 3600,
1060+
payload: {
1061+
// Define the user information stored inside the access tokens
1062+
groups: ['testgroup'],
1063+
sub: 'testuser',
1064+
aud: 'resource-server-audience-value',
1065+
},
1066+
};
1067+
1068+
before(async function () {
1069+
if (+process.version.slice(1).split('.')[0] < 16) {
1070+
// JWK support for Node.js KeyObject.export() is only Node.js 16+
1071+
// but the OIDCMockProvider implementation needs it.
1072+
return this.skip();
1073+
}
1074+
provider = await OIDCMockProvider.create({
1075+
getTokenPayload(metadata: TokenMetadata) {
1076+
return getTokenPayload(metadata);
1077+
},
1078+
additionalIssuerMetadata() {
1079+
return additionalIssuerMetadata?.() ?? {};
1080+
},
1081+
overrideRequestHandler(url: string) {
1082+
receivedHttpRequests.push(url);
1083+
},
1084+
});
1085+
});
1086+
1087+
after(async function () {
1088+
await provider?.close?.();
1089+
});
1090+
1091+
beforeEach(function () {
1092+
receivedHttpRequests = [];
1093+
getTokenPayload = () => tokenPayload;
1094+
additionalIssuerMetadata = undefined;
1095+
});
1096+
1097+
context('with different supported built-in scopes', function () {
1098+
let getScopes: () => Promise<string[]>;
1099+
1100+
beforeEach(function () {
1101+
getScopes = async function () {
1102+
const plugin = createMongoDBOIDCPlugin({
1103+
openBrowserTimeout: 60_000,
1104+
openBrowser: fetchBrowser,
1105+
allowedFlows: ['auth-code'],
1106+
redirectURI: 'http://localhost:0/callback',
1107+
});
1108+
const result = await requestToken(plugin, {
1109+
issuer: provider.issuer,
1110+
clientId: 'mockclientid',
1111+
requestScopes: [],
1112+
});
1113+
const accessTokenContents = getJWTContents(result.accessToken);
1114+
return String(accessTokenContents.scope).split(' ').sort();
1115+
};
1116+
});
1117+
1118+
it('will get a list of built-in OpenID scopes by default', async function () {
1119+
additionalIssuerMetadata = undefined;
1120+
expect(await getScopes()).to.deep.equal(['offline_access', 'openid']);
1121+
});
1122+
1123+
it('will omit built-in scopes if the IdP does not announce support for them', async function () {
1124+
additionalIssuerMetadata = () => ({ scopes_supported: ['openid'] });
1125+
expect(await getScopes()).to.deep.equal(['openid']);
1126+
});
1127+
});
1128+
1129+
context('HTTP request tracking', function () {
1130+
it('will log all outgoing HTTP requests', async function () {
1131+
const pluginHttpRequests: string[] = [];
1132+
const localServerHttpRequests: string[] = [];
1133+
const browserHttpRequests: string[] = [];
1134+
1135+
const plugin = createMongoDBOIDCPlugin({
1136+
openBrowserTimeout: 60_000,
1137+
openBrowser: async ({ url }) => {
1138+
// eslint-disable-next-line no-constant-condition
1139+
while (true) {
1140+
browserHttpRequests.push(url);
1141+
const response = await fetch(url, { redirect: 'manual' });
1142+
response.body?.resume();
1143+
const redirectTarget =
1144+
response.status >= 300 &&
1145+
response.status < 400 &&
1146+
response.headers.get('location');
1147+
if (redirectTarget)
1148+
url = new URL(redirectTarget, response.url).href;
1149+
else break;
1150+
}
1151+
},
1152+
allowedFlows: ['auth-code'],
1153+
redirectURI: 'http://localhost:0/callback',
1154+
});
1155+
plugin.logger.on('mongodb-oidc-plugin:outbound-http-request', (ev) =>
1156+
pluginHttpRequests.push(ev.url)
1157+
);
1158+
plugin.logger.on('mongodb-oidc-plugin:inbound-http-request', (ev) =>
1159+
localServerHttpRequests.push(ev.url)
1160+
);
1161+
await requestToken(plugin, {
1162+
issuer: provider.issuer,
1163+
clientId: 'mockclientid',
1164+
requestScopes: [],
1165+
});
1166+
1167+
const removeSearchParams = (str: string) =>
1168+
Object.assign(new URL(str), { search: '' }).toString();
1169+
const allOutboundRequests = [
1170+
...pluginHttpRequests,
1171+
...browserHttpRequests,
1172+
]
1173+
.map(removeSearchParams)
1174+
.sort();
1175+
const allInboundRequests = [
1176+
...localServerHttpRequests,
1177+
...receivedHttpRequests,
1178+
]
1179+
.map(removeSearchParams)
1180+
.sort();
1181+
expect(allOutboundRequests).to.deep.equal(allInboundRequests);
1182+
});
1183+
});
1184+
});

0 commit comments

Comments
 (0)