diff --git a/.github/workflows/actions/test-and-build/action.yaml b/.github/workflows/actions/test-and-build/action.yaml index 07f558515..ad06d2a54 100644 --- a/.github/workflows/actions/test-and-build/action.yaml +++ b/.github/workflows/actions/test-and-build/action.yaml @@ -76,7 +76,7 @@ runs: - name: Build .vsix env: - NODE_OPTIONS: "--require ./scripts/no-npm-list-fail.js" + NODE_OPTIONS: "--require ./scripts/no-npm-list-fail.js --max_old_space_size=4096" # NOTE: --githubBranch is "The GitHub branch used to infer relative links in README.md." run: | npx vsce package --githubBranch main diff --git a/README.md b/README.md index 50b0cfe91..72e80d5a1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ If you use Terraform to manage your infrastructure, MongoDB for VS Code helps yo | `mdb.defaultLimit` | The number of documents to fetch when viewing documents from a collection. | `10` | | `mdb.confirmRunAll` | Show a confirmation message before running commands in a playground. | `true` | | `mdb.confirmDeleteDocument` | Show a confirmation message before deleting a document in the tree view. | `true` | +| `mdb.persistOIDCTokens` | Remain logged in when using the MONGODB-OIDC authentication mechanism for MongoDB server connection. Access tokens are encrypted using the system keychain before being stored. | `true` | | `mdb.excludeFromPlaygroundsSearch` | Exclude files and folders while searching for playground files in the current workspace. | Refer to [`package.json`](https://github.com/mongodb-js/vscode/blob/7b10092db4c8c10c4aa9c45b443c8ed3d5f37d5c/package.json) | | `mdb.connectionSaving.` `hideOptionToChooseWhereToSaveNewConnections` | When a connection is added, a prompt is shown that let's the user decide where the new connection should be saved. When this setting is checked, the prompt is not shown and the default connection saving location setting is used. | `true` | | `mdb.connectionSaving.` `defaultConnectionSavingLocation` | When the setting that hides the option to choose where to save new connections is checked, this setting sets if and where new connections are saved. | `Global` | diff --git a/package-lock.json b/package-lock.json index 2ac376da9..081f2119d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "classnames": "^2.3.2", "debug": "^4.3.4", "dotenv": "^16.3.1", + "lodash": "^4.17.21", "micromatch": "^4.0.5", "mongodb": "^6.0.0", "mongodb-build-info": "^1.6.2", diff --git a/package.json b/package.json index 78b7cad08..5b616a37f 100644 --- a/package.json +++ b/package.json @@ -932,6 +932,11 @@ "default": true, "description": "Show a confirmation message before deleting a document from the tree view." }, + "mdb.persistOIDCTokens": { + "type": "boolean", + "default": true, + "description": "Remain logged in when using the MONGODB-OIDC authentication mechanism for MongoDB server connection. Access tokens are encrypted using the system keychain before being stored." + }, "mdb.sendTelemetry": { "type": "boolean", "default": true, @@ -991,6 +996,7 @@ "classnames": "^2.3.2", "debug": "^4.3.4", "dotenv": "^16.3.1", + "lodash": "^4.17.21", "micromatch": "^4.0.5", "mongodb": "^6.0.0", "mongodb-build-info": "^1.6.2", diff --git a/scripts/check-vsix-size.ts b/scripts/check-vsix-size.ts index 0492e2161..1e051fdd1 100644 --- a/scripts/check-vsix-size.ts +++ b/scripts/check-vsix-size.ts @@ -12,7 +12,7 @@ const vsixFileName = path.resolve( ); const size = fs.statSync(vsixFileName).size; -const maxSize = 8 * 1000000; // 8 MB +const maxSize = 8_500_000; // 8.5 MB if (size >= maxSize) { throw new Error( diff --git a/src/commands/launchMongoShell.ts b/src/commands/launchMongoShell.ts index b71132346..e8fce2013 100644 --- a/src/commands/launchMongoShell.ts +++ b/src/commands/launchMongoShell.ts @@ -2,15 +2,26 @@ import * as vscode from 'vscode'; import type ConnectionController from '../connectionController'; -const launchMongoDBShellWithEnv = ( - shellCommand: string, - mdbConnectionString: string, - envVariableString: string -) => { +const launchMongoDBShellWithEnv = ({ + shellCommand, + mdbConnectionString, + envVariableString, + parentHandle, +}: { + shellCommand: string; + mdbConnectionString: string; + envVariableString: string; + parentHandle?: string; +}) => { const mongoDBShell = vscode.window.createTerminal({ name: 'MongoDB Shell', env: { MDB_CONNECTION_STRING: mdbConnectionString, + ...(parentHandle + ? { + MONGOSH_OIDC_PARENT_HANDLE: parentHandle, // For OIDC to share the state and avoid extra logins. + } + : {}), }, }); @@ -18,48 +29,20 @@ const launchMongoDBShellWithEnv = ( mongoDBShell.show(); }; -const launchMongoDBShellOnPowershell = ( - shellCommand: string, - mdbConnectionString: string -): void => { - launchMongoDBShellWithEnv( - shellCommand, - mdbConnectionString, - '$Env:MDB_CONNECTION_STRING' - ); +const getPowershellEnvString = () => { + return '$Env:MDB_CONNECTION_STRING'; }; -const launchMongoDBShellOnCmd = ( - shellCommand: string, - mdbConnectionString: string -): void => { - launchMongoDBShellWithEnv( - shellCommand, - mdbConnectionString, - '%MDB_CONNECTION_STRING%' - ); +const getCmdEnvString = () => { + return '%MDB_CONNECTION_STRING%'; }; -const launchMongoDBShellOnGitBash = ( - shellCommand: string, - mdbConnectionString: string -): void => { - launchMongoDBShellWithEnv( - shellCommand, - mdbConnectionString, - '$MDB_CONNECTION_STRING' - ); +const getGitBashEnvString = () => { + return '$MDB_CONNECTION_STRING'; }; -const launchMongoDBShellOnBash = ( - shellCommand: string, - mdbConnectionString: string -): void => { - launchMongoDBShellWithEnv( - shellCommand, - mdbConnectionString, - '$MDB_CONNECTION_STRING' - ); +const getBashEnvString = () => { + return '$MDB_CONNECTION_STRING'; }; const openMongoDBShell = ( @@ -94,19 +77,31 @@ const openMongoDBShell = ( } const mdbConnectionString = connectionController.getActiveConnectionString(); + const parentHandle = + connectionController.getMongoClientConnectionOptions()?.options + .parentHandle; + + let envVariableString = ''; if (userShell.includes('powershell.exe')) { - launchMongoDBShellOnPowershell(shellCommand, mdbConnectionString); + envVariableString = getPowershellEnvString(); } else if (userShell.includes('cmd.exe')) { - launchMongoDBShellOnCmd(shellCommand, mdbConnectionString); + envVariableString = getCmdEnvString(); } else if (userShell.toLocaleLowerCase().includes('git\\bin\\bash.exe')) { - launchMongoDBShellOnGitBash(shellCommand, mdbConnectionString); + envVariableString = getGitBashEnvString(); } else { // Assume it's a bash environment. This may fail on certain // shells but should cover most cases. - launchMongoDBShellOnBash(shellCommand, mdbConnectionString); + envVariableString = getBashEnvString(); } + launchMongoDBShellWithEnv({ + shellCommand, + mdbConnectionString, + parentHandle, + envVariableString, + }); + return Promise.resolve(true); }; diff --git a/src/connectionController.ts b/src/connectionController.ts index bd3d14ea9..f831773a7 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -9,6 +9,7 @@ import ConnectionString from 'mongodb-connection-string-url'; import { EventEmitter } from 'events'; import type { MongoClientOptions } from 'mongodb'; import { v4 as uuidv4 } from 'uuid'; +import { cloneDeep, merge } from 'lodash'; import { mongoLogId } from 'mongodb-log-writer'; import type { ConnectionInfo as ConnectionInfoFromLegacyDS, @@ -18,6 +19,7 @@ import { extractSecrets, convertConnectionModelToInfo, } from 'mongodb-data-service-legacy'; +import { adjustConnectionOptionsBeforeConnect } from '@mongodb-js/connection-form'; import { CONNECTION_STATUS } from './views/webview-app/extension-app-message-constants'; import { createLogger } from './logging'; @@ -26,17 +28,11 @@ import type LegacyConnectionModel from './views/webview-app/legacy/connection-mo import type { StorageController } from './storage'; import type { StatusView } from './views'; import type TelemetryService from './telemetry/telemetryService'; +import { openLink } from './utils/linkHelper'; import type { LoadedConnection } from './storage/connectionStorage'; import { ConnectionStorage } from './storage/connectionStorage'; import LINKS from './utils/links'; -export function launderConnectionOptionTypeFromLegacyToCurrent( - opts: ConnectionOptionsFromLegacyDS -): ConnectionOptionsFromCurrentDS { - // Ensure that, at most, the types for OIDC mismatch here. - return opts as Omit; -} - // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJSON = require('../package.json'); @@ -70,6 +66,21 @@ interface ConnectionQuickPicks { data: { type: NewConnectionType; connectionId?: string }; } +type RecursivePartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object | undefined + ? RecursivePartial + : T[P]; +}; + +export function launderConnectionOptionTypeFromLegacyToCurrent( + opts: ConnectionOptionsFromLegacyDS +): ConnectionOptionsFromCurrentDS { + // Ensure that, at most, the types for OIDC mismatch here. + return opts as Omit; +} + export default class ConnectionController { // This is a map of connection ids to their configurations. // These connections can be saved on the session (runtime), @@ -77,6 +88,14 @@ export default class ConnectionController { _connections: { [connectionId: string]: LoadedConnection; } = Object.create(null); + // Additional connection information that is merged with the connections + // when connecting. This is useful for instances like OIDC sessions where we + // have a setting on the system for storing credentials. + // When the setting is on this `connectionMergeInfos` would have the session + // credential information and merge it before connecting. + connectionMergeInfos: Record> = + Object.create(null); + _activeDataService: DataService | null = null; _connectionStorage: ConnectionStorage; _telemetryService: TelemetryService; @@ -287,6 +306,15 @@ export default class ConnectionController { }; } + const connectionInfo: LoadedConnection = merge( + cloneDeep(this._connections[connectionId]), + this.connectionMergeInfos[connectionId] ?? {} + ); + + if (!connectionInfo.connectionOptions) { + throw new Error('Connect failed: connectionOptions are missing.'); + } + this._statusView.showMessage('Connecting to MongoDB...'); log.info('Connecting to MongoDB...', { connectionInfo: JSON.stringify( @@ -294,17 +322,37 @@ export default class ConnectionController { ), }); - const connectionOptions = this._connections[connectionId].connectionOptions; - - if (!connectionOptions) { - throw new Error('Connect failed: connectionOptions are missing.'); - } - let dataService; try { - dataService = await connectionAttempt.connect( - launderConnectionOptionTypeFromLegacyToCurrent(connectionOptions) - ); + const connectionOptions = adjustConnectionOptionsBeforeConnect({ + connectionOptions: launderConnectionOptionTypeFromLegacyToCurrent( + connectionInfo.connectionOptions + ), + defaultAppName: packageJSON.name, + notifyDeviceFlow: undefined, + preferences: { + forceConnectionOptions: [], + browserCommandForOIDCAuth: undefined, // We overwrite this below. + }, + }); + dataService = await connectionAttempt.connect({ + ...connectionOptions, + oidc: { + ...cloneDeep(connectionOptions.oidc), + openBrowser: async ({ signal, url }) => { + try { + await openLink(url); + } catch (err) { + if (signal.aborted) return; + // If opening the link fails we default to regular link opening. + await vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse(url) + ); + } + }, + }, + }); if (!dataService || connectionAttempt.isClosed()) { return { @@ -330,6 +378,9 @@ export default class ConnectionController { log.info('Successfully connected', { connectionId }); void vscode.window.showInformationMessage('MongoDB connection successful.'); + dataService.addReauthenticationHandler( + this._reauthenticationHandler.bind(this) + ); this._activeDataService = dataService; this._currentConnectionId = connectionId; this._connectionAttempt = null; @@ -346,12 +397,95 @@ export default class ConnectionController { true ); + void this.onConnectSuccess({ + connectionInfo, + dataService, + }); + return { successfullyConnected: true, connectionErrorMessage: '', }; } + // Used to re-authenticate with OIDC. + async _reauthenticationHandler() { + const removeConfirmationResponse = + await vscode.window.showInformationMessage( + 'You need to re-authenticate to the database in order to continue.', + { modal: true }, + 'Confirm' + ); + + if (removeConfirmationResponse !== 'Confirm') { + throw new Error('Reauthentication declined by user'); + } + } + + private async onConnectSuccess({ + connectionInfo, + dataService, + }: { + connectionInfo: LoadedConnection; + dataService: DataService; + }) { + if (connectionInfo.storageLocation === 'NONE') { + return; + } + + let mergeConnectionInfo: LoadedConnection | {} = {}; + if (vscode.workspace.getConfiguration('mdb').get('persistOIDCTokens')) { + mergeConnectionInfo = { + connectionOptions: await dataService.getUpdatedSecrets(), + }; + this.connectionMergeInfos[connectionInfo.id] = merge( + cloneDeep(this.connectionMergeInfos[connectionInfo.id]), + mergeConnectionInfo + ); + } + + await this._connectionStorage.saveConnection({ + ...merge( + this._connections[connectionInfo.id] ?? connectionInfo, + mergeConnectionInfo + ), + }); + + // ?. because mocks in tests don't provide it + dataService.on?.('connectionInfoSecretsChanged', () => { + void (async () => { + try { + if ( + !vscode.workspace.getConfiguration('mdb').get('persistOIDCTokens') + ) { + return; + } + // Get updated secrets first (and not in parallel) so that the + // race condition window between load() and save() is as short as possible. + const mergeConnectionInfo = { + connectionOptions: await dataService.getUpdatedSecrets(), + }; + if (!mergeConnectionInfo) return; + this.connectionMergeInfos[connectionInfo.id] = merge( + cloneDeep(this.connectionMergeInfos[connectionInfo.id]), + mergeConnectionInfo + ); + + if (!this._connections[connectionInfo.id]) return; + await this._connectionStorage.saveConnection({ + ...merge(this._connections[connectionInfo.id], mergeConnectionInfo), + }); + } catch (err: any) { + log.warn( + 'Connection Controller', + 'Failed to update connection store with updated secrets', + { err: err?.stack } + ); + } + })(); + }); + } + cancelConnectionAttempt() { this._connectionAttempt?.cancelConnectionAttempt(); } diff --git a/src/storage/connectionStorage.ts b/src/storage/connectionStorage.ts index 1735174ca..2cbfd2d46 100644 --- a/src/storage/connectionStorage.ts +++ b/src/storage/connectionStorage.ts @@ -113,7 +113,7 @@ export class ConnectionStorage { storeConnectionInfo.storageLocation ) ) { - await this.saveConnectionToStore(storeConnectionInfo); + await this._saveConnectionToStore(storeConnectionInfo); } return storeConnectionInfo; @@ -161,7 +161,7 @@ export class ConnectionStorage { return savedConnectionInfo; } - async saveConnectionToStore( + async _saveConnectionToStore( storeConnectionInfo: StoreConnectionInfo ): Promise { const variableName = @@ -229,13 +229,32 @@ export class ConnectionStorage { ), }); - return ( + const loadedConnections = ( await Promise.all( globalAndWorkspaceConnections.map(async (connectionInfo) => { return await this._getConnectionInfoWithSecrets(connectionInfo); }) ) - ).filter((connection) => !!connection) as LoadedConnection[]; + ).filter((connection): connection is LoadedConnection => !!connection); + + const toBeReSaved: LoadedConnection[] = []; + // Scrub OIDC tokens from connections when the option to store them has been disabled. + if (!vscode.workspace.getConfiguration('mdb').get('persistOIDCTokens')) { + for (const connection of loadedConnections) { + if (connection.connectionOptions.oidc?.serializedState) { + delete connection.connectionOptions.oidc?.serializedState; + toBeReSaved.push(connection); + } + } + } + + await Promise.all( + toBeReSaved.map(async (connectionInfo) => { + await this.saveConnectionWithSecrets(connectionInfo); + }) + ); + + return loadedConnections; } async removeConnection(connectionId: string) { diff --git a/src/test/suite/commands/launchMongoShell.test.ts b/src/test/suite/commands/launchMongoShell.test.ts index 3bd7d3e08..2c1e1b919 100644 --- a/src/test/suite/commands/launchMongoShell.test.ts +++ b/src/test/suite/commands/launchMongoShell.test.ts @@ -96,7 +96,9 @@ suite('Commands Test Suite', () => { getMongoClientConnectionOptionsStub.returns({ url: 'mongodb://localhost:27088/?readPreference=primary&ssl=false', - options: {}, + options: { + parentHandle: 'pineapple', + }, }); isCurrentlyConnectedStub.returns(true); @@ -110,6 +112,10 @@ suite('Commands Test Suite', () => { terminalOptions.env?.MDB_CONNECTION_STRING === expectedDriverUrl, `Expected open terminal to set shell arg as driver url "${expectedDriverUrl}" found "${terminalOptions.env?.MDB_CONNECTION_STRING}"` ); + assert.strictEqual( + terminalOptions.env?.MONGOSH_OIDC_PARENT_HANDLE, + 'pineapple' + ); const shellCommandText = sendTextStub.firstCall.args[0]; assert( @@ -144,6 +150,10 @@ suite('Commands Test Suite', () => { terminalOptions.env?.MDB_CONNECTION_STRING === expectedDriverUrl, `Expected open terminal to set shell arg as driver url "${expectedDriverUrl}" found "${terminalOptions.env?.MDB_CONNECTION_STRING}"` ); + assert.strictEqual( + terminalOptions.env?.MONGOSH_OIDC_PARENT_HANDLE, + undefined + ); }); }); }); diff --git a/src/test/suite/connectionController.test.ts b/src/test/suite/connectionController.test.ts index 4222d7a9b..abd4639ae 100644 --- a/src/test/suite/connectionController.test.ts +++ b/src/test/suite/connectionController.test.ts @@ -793,7 +793,7 @@ suite('Connection Controller Test Suite', function () { 'mongodb://localhost:27017/?readPreference=primary&ssl=false', }, }; - await testConnectionController._connectionStorage.saveConnectionToStore( + await testConnectionController._connectionStorage._saveConnectionToStore( connectionInfo ); await testConnectionController.loadSavedConnections(); @@ -911,6 +911,7 @@ suite('Connection Controller Test Suite', function () { assert(mongoClientConnectionOptions !== undefined); delete mongoClientConnectionOptions.options.parentHandle; + delete mongoClientConnectionOptions.options.oidc?.openBrowser; assert.deepStrictEqual(mongoClientConnectionOptions, { url: 'mongodb://localhost:27088/?appname=mongodb-vscode+0.0.0-dev.0', diff --git a/src/test/suite/storage/connectionStorage.test.ts b/src/test/suite/storage/connectionStorage.test.ts index ca1f4a3ad..fd576d368 100644 --- a/src/test/suite/storage/connectionStorage.test.ts +++ b/src/test/suite/storage/connectionStorage.test.ts @@ -288,7 +288,7 @@ suite('Connection Storage Test Suite', function () { 'mongodb://localhost:27017/?readPreference=primary&ssl=false', }, }; - await testConnectionStorage.saveConnectionToStore(connectionInfo); + await testConnectionStorage._saveConnectionToStore(connectionInfo); const connections = await testConnectionStorage.loadConnections(); expect(connections.length).to.equal(1); diff --git a/src/test/suite/views/webviewController.test.ts b/src/test/suite/views/webviewController.test.ts index ec70baeb1..48417e107 100644 --- a/src/test/suite/views/webviewController.test.ts +++ b/src/test/suite/views/webviewController.test.ts @@ -433,8 +433,10 @@ suite('Webview Test Suite', () => { postMessage: (message): void => { try { assert.strictEqual(message.connectionSuccess, false); - const expectedMessage = 'connection attempt cancelled'; - assert.strictEqual(message.connectionMessage, expectedMessage); + assert.strictEqual( + message.connectionMessage, + 'connection attempt cancelled' + ); void testConnectionController.disconnect(); done(); diff --git a/src/views/webview-app/connection-form.tsx b/src/views/webview-app/connection-form.tsx index dd3255615..42e339ba5 100644 --- a/src/views/webview-app/connection-form.tsx +++ b/src/views/webview-app/connection-form.tsx @@ -55,7 +55,7 @@ const initialConnectionInfo = createNewConnectionInfo(); const ConnectionForm: React.FunctionComponent<{ isConnecting: boolean; onCancelConnectClicked: () => void; - onConnectClicked: (onConnectClicked: ConnectionInfo) => void; + onConnectClicked: (connectionInfo: ConnectionInfo) => void; onClose: () => void; open: boolean; connectionErrorMessage: string; @@ -102,10 +102,10 @@ const ConnectionForm: React.FunctionComponent<{ forceConnectionOptions: [], showKerberosPasswordField: false, showOIDCDeviceAuthFlow: false, - enableOidc: false, + enableOidc: true, enableDebugUseCsfleSchemaMap: false, protectConnectionStringsForNewConnections: false, - showOIDCAuth: false, + showOIDCAuth: true, showKerberosAuth: false, showCSFLE: false, }}