From 481d781889926e46b60aea0433e7cd1fc7a999fc Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 3 Jul 2025 17:18:15 +0200 Subject: [PATCH 1/2] feat: add `codeName` to all OIDC errors coming from the plugin It came up in conversations that OIDC connections in Compass fail frequently, but we do not have real insight into why that is and what the most common errors are overall. --- src/plugin.ts | 69 ++++++++++++++++++++++++++----------- src/rfc-8252-http-server.ts | 22 ++++++++---- src/types.ts | 8 +++-- tsconfig.json | 5 +-- 4 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index efaaa43..58c1c7a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -253,7 +253,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { throw new MongoDBOIDCError( `Stored OIDC data could not be deserialized: ${ (err as Error).message - }` + }`, + { cause: err, codeName: 'DeserializeFormatMismatch' } ); } @@ -261,7 +262,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { throw new MongoDBOIDCError( `Stored OIDC data could not be deserialized because of a version mismatch (got ${JSON.stringify( original.oidcPluginStateVersion - )}, expected 1)` + )}, expected 1)`, + { codeName: 'DeserializeVersionMismatch' } ); } @@ -353,13 +355,16 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { serverMetadata: IdPServerInfo & Pick ): UserOIDCAuthState { if (!serverMetadata.issuer || typeof serverMetadata.issuer !== 'string') { - throw new MongoDBOIDCError(`'issuer' is missing`); + throw new MongoDBOIDCError(`'issuer' is missing`, { + codeName: 'MissingIssuer', + }); } validateSecureHTTPUrl(serverMetadata.issuer, 'issuer'); if (!serverMetadata.clientId) { throw new MongoDBOIDCError( - 'No clientId passed in server OIDC metadata object' + 'No clientId passed in server OIDC metadata object', + { codeName: 'MissingClientId' } ); } @@ -502,6 +507,7 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { )}: ${messageFromError(err)}`, { cause: err, + codeName: 'IssuerMetadataDiscoveryFailed', } ); } @@ -538,7 +544,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { new URL(options.url); if (!/^[a-zA-Z0-9%/:;_.,=@-]+$/.test(options.url)) { throw new MongoDBOIDCError( - `Unexpected format for internally generated URL: '${options.url}'` + `Unexpected format for internally generated URL: '${options.url}'`, + { codeName: 'GeneratedUrlInvalidForOpenBrowserCommand' } ); } this.logger.emit('mongodb-oidc-plugin:open-browser', { @@ -547,7 +554,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { if (this.options.openBrowser === false) { // We should never really get to this point throw new MongoDBOIDCError( - 'Cannot open browser if `openBrowser` is false' + 'Cannot open browser if `openBrowser` is false', + { codeName: 'OpenBrowserDisabled' } ); } if (typeof this.options.openBrowser === 'function') { @@ -567,7 +575,9 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { child.unref(); return child; } - throw new MongoDBOIDCError('Unknown format for `openBrowser`'); + throw new MongoDBOIDCError('Unknown format for `openBrowser`', { + codeName: 'OpenBrowserOptionFormatUnknown', + }); } private async notifyDeviceFlow( @@ -580,7 +590,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { if (!this.options.notifyDeviceFlow) { // Should never happen. throw new MongoDBOIDCError( - 'notifyDeviceFlow() requested but not provided' + 'notifyDeviceFlow() requested but not provided', + { codeName: 'DeviceFlowNotEnabled' } ); } this.logger.emit('mongodb-oidc-plugin:notify-device-flow'); @@ -605,7 +616,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { throw new MongoDBOIDCError( `ID token expected, but not found. Expected claims: ${JSON.stringify( state.lastIdTokenClaims - )}` + )}`, + { codeName: 'IDTokenClaimsMismatchTokenMissing' } ); } @@ -614,14 +626,17 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { state.lastIdTokenClaims && state.lastIdTokenClaims.noIdToken ) { - throw new MongoDBOIDCError(`Unexpected ID token received.`); + throw new MongoDBOIDCError(`Unexpected ID token received.`, { + codeName: 'IDTokenClaimsMismatchTokenUnexpectedlyPresent', + }); } if (tokenSet.idToken) { const idTokenClaims = tokenSet.idTokenClaims; if (!idTokenClaims) throw new MongoDBOIDCError( - 'Internal error: id_token set but claims() unavailable' + 'Internal error: id_token set but claims() unavailable', + { codeName: 'IDTokenClaimsUnavailable' } ); if (state.lastIdTokenClaims && !state.lastIdTokenClaims.noIdToken) { for (const claim of ['aud', 'sub'] as const) { @@ -635,7 +650,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { if (knownClaim !== newClaim) { throw new MongoDBOIDCError( - `Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}` + `Unexpected '${claim}' field in id token: Expected ${knownClaim}, saw ${newClaim}`, + { codeName: 'IDTokenClaimsMismatchClaimMismatch' } ); } } @@ -807,7 +823,7 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { `Opening browser failed with '${messageFromError( err )}'${extraErrorInfo()}`, - { cause: err } + { cause: err, codeName: 'BrowserOpenFailedSpawnError' } ) ) ); @@ -815,7 +831,8 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { if (code !== 0) reject( new MongoDBOIDCError( - `Opening browser failed with exit code ${code}${extraErrorInfo()}` + `Opening browser failed with exit code ${code}${extraErrorInfo()}`, + { codeName: 'BrowserOpenFailedNonZeroExit' } ) ); }); @@ -831,7 +848,11 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { .call( null, () => - reject(new MongoDBOIDCError('Opening browser timed out')), + reject( + new MongoDBOIDCError('Opening browser timed out', { + codeName: 'BrowserOpenTimeout', + }) + ), this.options.openBrowserTimeout ?? kDefaultOpenBrowserTimeout ) ?.unref?.(); @@ -988,9 +1009,13 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { } if (passIdTokenAsAccessToken && !state.currentTokenSet?.set?.idToken) { - throw new MongoDBOIDCError('Could not retrieve valid ID token'); + throw new MongoDBOIDCError('Could not retrieve valid ID token', { + codeName: 'IDTokenMissingFromTokenSet', + }); } else if (!state.currentTokenSet?.set?.accessToken) { - throw new MongoDBOIDCError('Could not retrieve valid access token'); + throw new MongoDBOIDCError('Could not retrieve valid access token', { + codeName: 'AccessTokenMissingFromTokenSet', + }); } } catch (err: unknown) { this.logger.emit('mongodb-oidc-plugin:auth-failed', { @@ -1058,18 +1083,22 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { if (params.version !== 1) { throw new MongoDBOIDCError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `OIDC MongoDB driver protocol mismatch: unknown version ${params.version}` + `OIDC MongoDB driver protocol mismatch: unknown version ${params.version}`, + { codeName: 'ProtocolVersionMismatch' } ); } if (this.destroyed) { throw new MongoDBOIDCError( - 'This OIDC plugin instance has been destroyed and is no longer active' + 'This OIDC plugin instance has been destroyed and is no longer active', + { codeName: 'PluginInstanceDestroyed' } ); } if (!params.idpInfo) { - throw new MongoDBOIDCError('No IdP information provided'); + throw new MongoDBOIDCError('No IdP information provided', { + codeName: 'IdPInfoMissing', + }); } const state = this.getAuthState({ diff --git a/src/rfc-8252-http-server.ts b/src/rfc-8252-http-server.ts index b4aec4c..a500870 100644 --- a/src/rfc-8252-http-server.ts +++ b/src/rfc-8252-http-server.ts @@ -164,7 +164,9 @@ export class RFC8252HTTPServer { private _handleOIDCCallback: RequestHandler = (req, res) => { const baseUrl = this.listeningRedirectUrl; if (!baseUrl) { - throw new MongoDBOIDCError('Received HTTP request while not listening'); + throw new MongoDBOIDCError('Received HTTP request while not listening', { + codeName: 'InvalidRequestNotListening', + }); } let isAcceptedOIDCResponse = false; @@ -193,7 +195,8 @@ export class RFC8252HTTPServer { new MongoDBOIDCError( `${info.error || 'unknown_code'}: ${ info.errorDescription || '[no details]' - }` + }`, + { codeName: 'IDPRejectedAuthCodeFlow' } ) ); } @@ -331,7 +334,8 @@ export class RFC8252HTTPServer { .join(','); // Should never happen throw new MongoDBOIDCError( - `Server is listening in inconsistent state: ${addressesDebugInfo}` + `Server is listening in inconsistent state: ${addressesDebugInfo}`, + { codeName: 'InvalidServerStatePortMismatch' } ); } return port; @@ -357,13 +361,15 @@ export class RFC8252HTTPServer { public async listen(): Promise { if (this.listeningPort !== undefined) { throw new MongoDBOIDCError( - `Already listening on ${this.redirectUrl.toString()}` + `Already listening on ${this.redirectUrl.toString()}`, + { codeName: 'InvalidServerStateAlreadyListening' } ); } if (this.redirectUrl.protocol !== 'http:') { throw new MongoDBOIDCError( - `Cannot handle listening on non-HTTP URL, got ${this.redirectUrl.protocol}` + `Cannot handle listening on non-HTTP URL, got ${this.redirectUrl.protocol}`, + { codeName: 'InvalidRedirectURLProtocol' } ); } @@ -403,7 +409,8 @@ export class RFC8252HTTPServer { if (dnsResults.length === 0) { throw new MongoDBOIDCError( - `DNS query for ${this.redirectUrl.hostname} returned no results` + `DNS query for ${this.redirectUrl.hostname} returned no results`, + { codeName: 'LocalEndpointResolutionFailedNoResults' } ); } @@ -436,7 +443,8 @@ export class RFC8252HTTPServer { if (typeof port !== 'number') { // Should never happen throw new MongoDBOIDCError( - `Listening on ${dnsResults[0].address} (family = ${dnsResults[0].family}) did not return a port` + `Listening on ${dnsResults[0].address} (family = ${dnsResults[0].family}) did not return a port`, + { codeName: 'LocalEndpointResolutionFailedNoPort' } ); } } diff --git a/src/types.ts b/src/types.ts index 861241b..9728900 100644 --- a/src/types.ts +++ b/src/types.ts @@ -211,10 +211,14 @@ const MongoDBOIDCErrorTag = Symbol.for('@@mdb.oidcplugin.MongoDBOIDCErrorTag'); export class MongoDBOIDCError extends Error { /** @internal */ private [MongoDBOIDCErrorTag] = true; + public readonly codeName: `MongoDBOIDC${string}`; - constructor(message: string, { cause }: { cause?: unknown } = {}) { - // @ts-expect-error `cause` is not supported in Node.js 14 + constructor( + message: string, + { cause, codeName }: { cause?: unknown; codeName: string } + ) { super(message, { cause }); + this.codeName = `MongoDBOIDC${codeName}`; } static isMongoDBOIDCError(value: unknown): value is MongoDBOIDCError { diff --git a/tsconfig.json b/tsconfig.json index 89915e3..ba3abff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,9 @@ "outDir": "dist", "removeComments": false, "sourceMap": true, - "target": "es2020", - "lib": ["es2020"], + "target": "es2023", + "lib": ["es2023"], + "module": "nodenext", "erasableSyntaxOnly": true }, "include": ["src/**/*"], From 8f91a5969b96e0a056d409b995918c7847644d7a Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 3 Jul 2025 18:08:48 +0200 Subject: [PATCH 2/2] fixup: undo module: nodenext --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index ba3abff..e5262a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ "sourceMap": true, "target": "es2023", "lib": ["es2023"], - "module": "nodenext", "erasableSyntaxOnly": true }, "include": ["src/**/*"],