Skip to content

Commit 802564b

Browse files
authored
fix: improve node-fetch compatibility in error cases MONGOSH-2443 (#224)
`node-fetch` currently does not return a body that is a web/standard `ReadableStream`, which is what openid-client expects. Since it only makes use of this in error cases, no additional cases are broken, but the error message is highly unhelpful in these cases. Unfortunately, it is not trivial to resolve this without fully wrapping the `node-fetch`, so this provides a limited compatibility layer rather than a full-featured one.
1 parent 444377a commit 802564b

File tree

3 files changed

+162
-19
lines changed

3 files changed

+162
-19
lines changed

src/plugin.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import { verifySuccessfulAuthCodeFlowLog } from '../test/log-hook-verification-h
2828
import { automaticRefreshTimeoutMS, MongoDBOIDCPluginImpl } from './plugin';
2929
import sinon from 'sinon';
3030
import { publicPluginToInternalPluginMap_DoNotUseOutsideOfTests } from './api';
31-
import type { Server as HTTPServer } from 'http';
31+
import type {
32+
Server as HTTPServer,
33+
ServerResponse,
34+
IncomingMessage,
35+
} from 'http';
3236
import { createServer as createHTTPServer } from 'http';
3337
import type { AddressInfo } from 'net';
3438
import type {
@@ -1245,6 +1249,7 @@ describe('OIDC plugin (local OIDC provider)', function () {
12451249
describe('OIDC plugin (mock OIDC provider)', function () {
12461250
let provider: OIDCMockProvider;
12471251
let getTokenPayload: OIDCMockProviderConfig['getTokenPayload'];
1252+
let overrideRequestHandler: OIDCMockProviderConfig['overrideRequestHandler'];
12481253
let additionalIssuerMetadata: OIDCMockProviderConfig['additionalIssuerMetadata'];
12491254
let receivedHttpRequests: string[] = [];
12501255
const tokenPayload = {
@@ -1270,8 +1275,13 @@ describe('OIDC plugin (mock OIDC provider)', function () {
12701275
additionalIssuerMetadata() {
12711276
return additionalIssuerMetadata?.() ?? {};
12721277
},
1273-
overrideRequestHandler(url: string) {
1278+
overrideRequestHandler(
1279+
url: string,
1280+
req: IncomingMessage,
1281+
res: ServerResponse
1282+
) {
12741283
receivedHttpRequests.push(url);
1284+
return overrideRequestHandler?.(url, req, res);
12751285
},
12761286
});
12771287
});
@@ -1283,6 +1293,7 @@ describe('OIDC plugin (mock OIDC provider)', function () {
12831293
beforeEach(function () {
12841294
receivedHttpRequests = [];
12851295
getTokenPayload = () => tokenPayload;
1296+
overrideRequestHandler = undefined;
12861297
additionalIssuerMetadata = undefined;
12871298
});
12881299

@@ -1583,5 +1594,39 @@ describe('OIDC plugin (mock OIDC provider)', function () {
15831594
statusText: 'Internal Server Error',
15841595
});
15851596
});
1597+
1598+
it('handles JSON failure responses from the IDP', async function () {
1599+
overrideRequestHandler = (url, req, res) => {
1600+
if (new URL(url).pathname.endsWith('/token')) {
1601+
res.writeHead(400, { 'Content-Type': 'application/json' });
1602+
res.end(
1603+
JSON.stringify({
1604+
error: 'jsonfailure',
1605+
error_description: 'failure text',
1606+
})
1607+
);
1608+
}
1609+
};
1610+
const plugin = createMongoDBOIDCPlugin({
1611+
openBrowserTimeout: 60_000,
1612+
openBrowser: fetchBrowser,
1613+
allowedFlows: ['auth-code'],
1614+
redirectURI: 'http://localhost:0/callback',
1615+
customFetch: sinon.stub().callsFake(fetch),
1616+
});
1617+
1618+
try {
1619+
await requestToken(plugin, {
1620+
issuer: provider.issuer,
1621+
clientId: 'mockclientid',
1622+
requestScopes: [],
1623+
});
1624+
expect.fail('missed exception');
1625+
} catch (err: any) {
1626+
expect(err.message).to.equal(
1627+
'server responded with an error in the response body: caused by HTTP response 400 : jsonfailure, failure text'
1628+
);
1629+
}
1630+
});
15861631
});
15871632
});

src/plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getRefreshTokenId,
1313
improveHTTPResponseBasedError,
1414
messageFromError,
15+
nodeFetchCompat,
1516
normalizeObject,
1617
throwIfAborted,
1718
TokenSet,
@@ -405,7 +406,7 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin {
405406
this.logger.emit('mongodb-oidc-plugin:outbound-http-request', { url });
406407

407408
try {
408-
const response = await this.doFetch(url, init);
409+
const response = nodeFetchCompat(await this.doFetch(url, init));
409410
this.logger.emit('mongodb-oidc-plugin:outbound-http-request-completed', {
410411
url,
411412
status: response.status,

src/util.ts

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from 'openid-client';
77
import { MongoDBOIDCError, type OIDCAbortSignal } from './types';
88
import { createHash, randomBytes } from 'crypto';
9+
import type { Readable } from 'stream';
910

1011
class AbortError extends Error {
1112
constructor() {
@@ -236,44 +237,67 @@ export class TokenSet {
236237
}
237238
}
238239

240+
function getCause(err: unknown): Record<string, unknown> | undefined {
241+
if (
242+
err &&
243+
typeof err === 'object' &&
244+
'cause' in err &&
245+
err.cause &&
246+
typeof err.cause === 'object'
247+
) {
248+
return err.cause as Record<string, unknown>;
249+
}
250+
}
251+
239252
// [email protected] has reduced error messages for HTTP errors significantly, reducing e.g.
240253
// an HTTP error to just a simple 'unexpect HTTP response status code' message, without
241254
// further diagnostic information. So if the `cause` of an `err` object is a fetch `Response`
242255
// object, we try to throw a more helpful error.
243256
export async function improveHTTPResponseBasedError<T>(
244257
err: T
245258
): Promise<T | MongoDBOIDCError> {
246-
if (
247-
err &&
248-
typeof err === 'object' &&
249-
'cause' in err &&
250-
err.cause &&
251-
typeof err.cause === 'object' &&
252-
'status' in err.cause &&
253-
'statusText' in err.cause &&
254-
'text' in err.cause &&
255-
typeof err.cause.text === 'function'
256-
) {
259+
// Note: `err.cause` can either be an `Error` object itself, or a `Response`, or a JSON HTTP response body
260+
const cause = getCause(err);
261+
if (cause) {
257262
try {
263+
const statusObject =
264+
'status' in cause ? cause : (err as Record<string, unknown>);
265+
if (!statusObject.status) return err;
266+
258267
let body = '';
259268
try {
260-
body = await err.cause.text();
269+
if ('text' in cause && typeof cause.text === 'function')
270+
body = await cause.text(); // Handle the `Response` case
261271
} catch {
262272
// ignore
263273
}
264274
let errorMessageFromBody = '';
265275
try {
266-
const parsed = JSON.parse(body);
276+
let parsed: Record<string, unknown> = cause;
277+
try {
278+
parsed = JSON.parse(body);
279+
} catch {
280+
// ignore, and maybe `parsed` already contains the parsed JSON body anyway
281+
}
267282
errorMessageFromBody =
268-
': ' + String(parsed.error_description || parsed.error || '');
283+
': ' +
284+
[parsed.error, parsed.error_description]
285+
.filter(Boolean)
286+
.map(String)
287+
.join(', ');
269288
} catch {
270289
// ignore
271290
}
272291
if (!errorMessageFromBody) errorMessageFromBody = `: ${body}`;
292+
293+
const statusTextInsert =
294+
'statusText' in statusObject
295+
? `(${String(statusObject.statusText)})`
296+
: '';
273297
return new MongoDBOIDCError(
274298
`${errorString(err)}: caused by HTTP response ${String(
275-
err.cause.status
276-
)} (${String(err.cause.statusText)})${errorMessageFromBody}`,
299+
statusObject.status
300+
)} ${statusTextInsert}${errorMessageFromBody}`,
277301
{ codeName: 'HTTPResponseError', cause: err }
278302
);
279303
} catch {
@@ -282,3 +306,76 @@ export async function improveHTTPResponseBasedError<T>(
282306
}
283307
return err;
284308
}
309+
310+
// Check whether converting a Node.js `Readable` stream to a web `ReadableStream`
311+
// is possible. We use this for compatibility with fetch() implementations that
312+
// return Node.js `Readable` streams like node-fetch.
313+
export function streamIsNodeReadable(stream: unknown): stream is Readable {
314+
return !!(
315+
stream &&
316+
typeof stream === 'object' &&
317+
'pipe' in stream &&
318+
typeof stream.pipe === 'function' &&
319+
(!('cancel' in stream) || !stream.cancel)
320+
);
321+
}
322+
323+
export function nodeFetchCompat(
324+
response: Response & { body: Readable | ReadableStream | null }
325+
): Response {
326+
const notImplemented = (method: string) =>
327+
new MongoDBOIDCError(`Not implemented: body.${method}`, {
328+
codeName: 'HTTPBodyShimNotImplemented',
329+
});
330+
const { body, clone } = response;
331+
if (streamIsNodeReadable(body)) {
332+
let webStream: ReadableStream | undefined;
333+
const toWeb = () =>
334+
webStream ?? (body.constructor as typeof Readable).toWeb?.(body);
335+
// Provide ReadableStream methods that may be used by openid-client
336+
Object.assign(
337+
body,
338+
{
339+
locked: false,
340+
cancel() {
341+
if (webStream) return webStream.cancel();
342+
body.resume();
343+
},
344+
getReader(...args: Parameters<ReadableStream['getReader']>) {
345+
if ((webStream = toWeb())) return webStream.getReader(...args);
346+
347+
throw notImplemented('getReader');
348+
},
349+
pipeThrough(...args: Parameters<ReadableStream['pipeThrough']>) {
350+
if ((webStream = toWeb())) return webStream.pipeThrough(...args);
351+
throw notImplemented('pipeThrough');
352+
},
353+
pipeTo(...args: Parameters<ReadableStream['pipeTo']>) {
354+
if ((webStream = toWeb())) return webStream.pipeTo(...args);
355+
356+
throw notImplemented('pipeTo');
357+
},
358+
tee(...args: Parameters<ReadableStream['tee']>) {
359+
if ((webStream = toWeb())) return webStream.tee(...args);
360+
throw notImplemented('tee');
361+
},
362+
values(...args: Parameters<ReadableStream['values']>) {
363+
if ((webStream = toWeb())) return webStream.values(...args);
364+
throw notImplemented('values');
365+
},
366+
},
367+
body
368+
);
369+
Object.assign(response, {
370+
clone: function (this: Response): Response {
371+
// node-fetch replaces `.body` on `.clone()` on *both*
372+
// the original and the cloned Response objects
373+
const cloned = clone.call(this);
374+
nodeFetchCompat(this);
375+
return nodeFetchCompat(cloned);
376+
},
377+
});
378+
}
379+
380+
return response;
381+
}

0 commit comments

Comments
 (0)