diff --git a/package-lock.json b/package-lock.json index aabba0913..0b58b314e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6803,9 +6803,9 @@ } }, "node_modules/@mongodb-js/oidc-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-1.1.0.tgz", - "integrity": "sha512-edf5cMYpuJHfbxAyc6d0fDxeO3bE50w9vagi4NDfUH+Pz3gVN4Uj7rQUYCKMwjuKVE8hxyVqTgd0oSYAqsLjmA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-1.1.1.tgz", + "integrity": "sha512-u2t3dvUpQJeTmMvXyZu730yJzqJ3aKraQ7ELlNwpKpl1AGxL6Dd9Z2AEu9ycExZjXhyjBW/lbaWuEhdNZHEgeg==", "license": "Apache-2.0", "dependencies": { "express": "^4.18.2", @@ -31718,7 +31718,7 @@ "license": "Apache-2.0", "dependencies": { "@mongodb-js/devtools-connect": "^3.0.5", - "@mongodb-js/oidc-plugin": "^1.1.0", + "@mongodb-js/oidc-plugin": "^1.1.1", "@mongosh/errors": "0.0.0-dev.0", "@mongosh/service-provider-core": "0.0.0-dev.0", "@mongosh/types": "0.0.0-dev.0", @@ -37212,9 +37212,9 @@ } }, "@mongodb-js/oidc-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-1.1.0.tgz", - "integrity": "sha512-edf5cMYpuJHfbxAyc6d0fDxeO3bE50w9vagi4NDfUH+Pz3gVN4Uj7rQUYCKMwjuKVE8hxyVqTgd0oSYAqsLjmA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-1.1.1.tgz", + "integrity": "sha512-u2t3dvUpQJeTmMvXyZu730yJzqJ3aKraQ7ELlNwpKpl1AGxL6Dd9Z2AEu9ycExZjXhyjBW/lbaWuEhdNZHEgeg==", "requires": { "express": "^4.18.2", "open": "^9.1.0", @@ -37987,7 +37987,7 @@ "requires": { "@mongodb-js/devtools-connect": "^3.0.5", "@mongodb-js/eslint-config-mongosh": "^1.0.0", - "@mongodb-js/oidc-plugin": "^1.1.0", + "@mongodb-js/oidc-plugin": "^1.1.1", "@mongodb-js/prettier-config-devtools": "^1.0.1", "@mongodb-js/tsconfig-mongosh": "^1.0.0", "@mongosh/errors": "0.0.0-dev.0", diff --git a/packages/arg-parser/src/cli-options.ts b/packages/arg-parser/src/cli-options.ts index 0eaab6ecb..d7a30e9a2 100644 --- a/packages/arg-parser/src/cli-options.ts +++ b/packages/arg-parser/src/cli-options.ts @@ -56,5 +56,6 @@ export interface CliOptions { oidcRedirectUri?: string; oidcTrustedEndpoint?: boolean; oidcIdTokenAsAccessToken?: boolean; + oidcDumpTokens?: boolean | 'redacted' | 'include-secrets'; browser?: string | false; } diff --git a/packages/cli-repl/src/arg-parser.ts b/packages/cli-repl/src/arg-parser.ts index 6092b4297..809639b22 100644 --- a/packages/cli-repl/src/arg-parser.ts +++ b/packages/cli-repl/src/arg-parser.ts @@ -91,6 +91,7 @@ const OPTIONS = { 'build-info': 'buildInfo', json: 'json', // List explicitly here since it can be a boolean or a string browser: 'browser', // ditto + oidcDumpTokens: 'oidcDumpTokens', // ditto oidcRedirectUrl: 'oidcRedirectUri', // I'd get this wrong about 50% of the time oidcIDTokenAsAccessToken: 'oidcIdTokenAsAccessToken', // ditto }, @@ -215,6 +216,17 @@ export function verifyCliArguments(args: any /* CliOptions */): string[] { ); } + if ( + ![undefined, true, false, 'redacted', 'include-secrets'].includes( + args.oidcDumpTokens + ) + ) { + throw new MongoshUnimplementedError( + '--oidcDumpTokens can only have the values redacted or include-secrets', + CommonErrors.InvalidArgument + ); + } + const messages = []; for (const deprecated in DEPRECATED_ARGS_WITH_REPLACEMENT) { if (deprecated in args) { diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts index 97427c5e3..d390ea107 100644 --- a/packages/cli-repl/src/cli-repl.ts +++ b/packages/cli-repl/src/cli-repl.ts @@ -47,6 +47,7 @@ import path from 'path'; import { getOsInfo } from './get-os-info'; import { UpdateNotificationManager } from './update-notification-manager'; import { getTimingData, markTime, summariseTimingData } from './startup-timing'; +import type { IdPInfo } from 'mongodb'; /** * Connecting text key. @@ -204,6 +205,8 @@ export class CliRepl implements MongoshIOProvider { bus: this.bus, ioProvider: this, }); + + this.setupOIDCTokenDumpListener(); } async getIsContainerizedEnvironment() { @@ -1254,4 +1257,100 @@ export class CliRepl implements MongoshIOProvider { version ); } + + private setupOIDCTokenDumpListener() { + function tryParseJWT( + token: string | null | undefined, + redact: 'redact' | 'include-secrets' + ): unknown { + if (!token) return token; + const jwtParts = token.split('.'); + if ( + // If this is a three-part token consisting of valid base64url-encoded + // parts (without trailing `=`), assume that it is a JWT access/id token. + jwtParts.length === 3 && + jwtParts.every( + (part) => + Buffer.from(part, 'base64url') + .toString('base64url') + .replace(/=+$/, '') === part.replace(/=+$/, '') + ) + ) { + const [header, payload] = jwtParts.map((part) => { + try { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); + } catch { + // Not a valid JWT in this case. + } + }); + if (redact === 'include-secrets') { + return { header, payload, signature: jwtParts[2] }; + } + if (header && payload) { + return { header, payload }; + } + } + return redact === 'include-secrets' ? token : ''; + } + + let lastServerIdPInfo: IdPInfo | undefined; + const { oidcDumpTokens } = this.cliOptions; + if (oidcDumpTokens) { + this.bus.on( + 'mongodb-oidc-plugin:received-server-params', + ({ params: { idpInfo } }) => { + lastServerIdPInfo = idpInfo; + } + ); + this.bus.on( + 'mongodb-oidc-plugin:auth-succeeded', + ({ + tokenType, + refreshToken, // only an identifier, not the actual token + expiresAt, + passIdTokenAsAccessToken, + tokens: { accessToken: at, refreshToken: rt, idToken: idt }, + }) => { + const printable = { + lastServerIdPInfo: lastServerIdPInfo && { + issuer: lastServerIdPInfo?.issuer, + clientId: lastServerIdPInfo?.clientId, + requestScopes: lastServerIdPInfo?.requestScopes, + }, + tokenType, + refreshToken, + expiresAt, + passIdTokenAsAccessToken, + tokens: + oidcDumpTokens === 'include-secrets' + ? { + accessToken: tryParseJWT(at, 'include-secrets'), + refreshToken: tryParseJWT(rt, 'include-secrets'), + idToken: tryParseJWT(idt, 'include-secrets'), + } + : { + accessToken: tryParseJWT(at, 'redact'), + idToken: tryParseJWT(idt, 'redact'), + }, + }; + + this.output.write( + '\n' + + this.clr( + '----- BEGIN OIDC TOKEN DUMP -----', + 'mongosh:section-header' + ) + + '\n' + + JSON.stringify(printable, null, 2) + + '\n' + + this.clr( + '----- END OIDC TOKEN DUMP -----', + 'mongosh:section-header' + ) + + '\n' + ); + } + ); + } + } } diff --git a/packages/cli-repl/src/constants.ts b/packages/cli-repl/src/constants.ts index a1f7d8706..d84224e37 100644 --- a/packages/cli-repl/src/constants.ts +++ b/packages/cli-repl/src/constants.ts @@ -138,6 +138,30 @@ export const USAGE = ` 'cli-repl.args.kmsURL' )} + oidcFlows?: string; + oidcRedirectUri?: string; + oidcTrustedEndpoint?: boolean; + oidcIdTokenAsAccessToken?: boolean; + oidcDumpTokens?: boolean | 'redacted' | 'include-secrets'; + + ${clr(i18n.__('cli-repl.args.oidcOptions'), 'mongosh:section-header')} + + --oidcFlows[=auth-code,device-auth] ${i18n.__( + 'cli-repl.args.oidcFlows' + )} + --oidcRedirectUri[=url] ${i18n.__( + 'cli-repl.args.oidcRedirectUri' + )} + --oidcTrustedEndpoint ${i18n.__( + 'cli-repl.args.oidcTrustedEndpoint' + )} + --oidcIdTokenAsAccessToken ${i18n.__( + 'cli-repl.args.oidcIdTokenAsAccessToken' + )} + --oidcDumpTokens[=mode] ${i18n.__( + 'cli-repl.args.oidcDumpTokens' + )} + ${clr(i18n.__('cli-repl.args.dbAddressOptions'), 'mongosh:section-header')} foo ${i18n.__( diff --git a/packages/e2e-tests/test/e2e-oidc.spec.ts b/packages/e2e-tests/test/e2e-oidc.spec.ts index d3c430a7b..2e4df628d 100644 --- a/packages/e2e-tests/test/e2e-oidc.spec.ts +++ b/packages/e2e-tests/test/e2e-oidc.spec.ts @@ -478,4 +478,46 @@ describe('OIDC auth e2e', function () { await verifyUser(shell, 'testuser-id', 'testuser-id-group'); shell.assertNoErrors(); }); + + it('can print tokens as debug information if requested', async function () { + shell = TestShell.start({ + args: [ + await testServer.connectionString(), + '--authenticationMechanism=MONGODB-OIDC', + '--oidcRedirectUri=http://localhost:0/', + '--oidcDumpTokens', + `--browser=${fetchBrowserFixture}`, + '--eval=42', + ], + }); + await shell.waitForExit(); + + shell.assertContainsOutput('BEGIN OIDC TOKEN DUMP'); + shell.assertContainsOutput('"tokenType": "Bearer"'); + shell.assertContainsOutput('"alg": "RS256"'); + shell.assertContainsOutput('"sub": "testuser"'); + shell.assertNotContainsOutput('"signature":'); + shell.assertContainsOutput('"lastServerIdPInfo":'); + shell.assertNotContainsOutput(/"refreshToken": "(?!debugid:)/); + + shell = TestShell.start({ + args: [ + await testServer.connectionString(), + '--authenticationMechanism=MONGODB-OIDC', + '--oidcRedirectUri=http://localhost:0/', + '--oidcDumpTokens=include-secrets', + `--browser=${fetchBrowserFixture}`, + '--eval=42', + ], + }); + await shell.waitForExit(); + + shell.assertContainsOutput('BEGIN OIDC TOKEN DUMP'); + shell.assertContainsOutput('"tokenType": "Bearer"'); + shell.assertContainsOutput('"alg": "RS256"'); + shell.assertContainsOutput('"sub": "testuser"'); + shell.assertContainsOutput('"signature":'); + shell.assertContainsOutput('"lastServerIdPInfo":'); + shell.assertContainsOutput(/"refreshToken": "(?!debugid:)/); + }); }); diff --git a/packages/i18n/src/locales/en_US.ts b/packages/i18n/src/locales/en_US.ts index 575804f70..edc05abc5 100644 --- a/packages/i18n/src/locales/en_US.ts +++ b/packages/i18n/src/locales/en_US.ts @@ -1,3 +1,4 @@ +/* eslint-disable filename-rules/match */ import type Catalog from '../catalog'; /** @@ -82,6 +83,16 @@ const translations: Catalog = { connectionExampleWithDatabase: "Start mongosh using 'ships' database on specified connection string:", moreInformation: 'For more information on usage:', + oidcOptions: 'OIDC auth options:', + oidcFlows: 'Supported OIDC auth flows', + oidcRedirectUri: + 'Local auth code flow redirect URL [http://localhost:27097/redirect]', + oidcTrustedEndpoint: + 'Treat the cluster/database mongosh as a trusted endpoint', + oidcIdTokenAsAccessToken: + 'Use ID tokens in place of access tokens for auth', + oidcDumpTokens: + "Debug OIDC by printing tokens to mongosh's output [full|include-secrets]", }, 'arg-parser': { 'unknown-option': 'Error parsing command line: unrecognized option:', diff --git a/packages/service-provider-server/package.json b/packages/service-provider-server/package.json index cf7981e0a..5edb6efc7 100644 --- a/packages/service-provider-server/package.json +++ b/packages/service-provider-server/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@mongodb-js/devtools-connect": "^3.0.5", - "@mongodb-js/oidc-plugin": "^1.1.0", + "@mongodb-js/oidc-plugin": "^1.1.1", "@mongosh/errors": "0.0.0-dev.0", "@mongosh/service-provider-core": "0.0.0-dev.0", "@mongosh/types": "0.0.0-dev.0", diff --git a/packages/service-provider-server/src/cli-service-provider.ts b/packages/service-provider-server/src/cli-service-provider.ts index a9123703b..b0f545d2f 100644 --- a/packages/service-provider-server/src/cli-service-provider.ts +++ b/packages/service-provider-server/src/cli-service-provider.ts @@ -147,7 +147,7 @@ const DEFAULT_BASE_OPTIONS: OperationOptions = Object.freeze({ /** * Pick properties of `uri` and `opts` that as a tuple that can be matched - * against the correspondiung tuple for another `uri` and `opts` configuration, + * against the corresponding tuple for another `uri` and `opts` configuration, * and when they do, it is meaningful to share connection state between them. * * Currently, this is only used for OIDC. We don't need to make sure that the