From 82de91d666e8990dd97464dce6a29b795f8a2fb2 Mon Sep 17 00:00:00 2001 From: Rhys Date: Tue, 16 Jan 2024 10:15:03 -0500 Subject: [PATCH] feat(connect-form): add OIDC device auth flow with preference VSCODE-503 (#658) --- README.md | 1 + package.json | 7 ++- src/connectionController.ts | 47 +++++++++++++- src/test/suite/connectionController.test.ts | 47 +++++++++++++- .../suite/views/webviewController.test.ts | 62 ++++++++++++++++++- src/views/webview-app/connection-form.tsx | 4 +- .../extension-app-message-constants.ts | 3 + src/views/webviewController.ts | 8 +++ 8 files changed, 174 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 72e80d5a1..0168c911e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ If you use Terraform to manage your infrastructure, MongoDB for VS Code helps yo | `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.showOIDCDeviceAuthFlow` | Opt-in and opt-out for diagnostic and telemetry collection. | `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.json b/package.json index 7f607fe82..303f56d04 100644 --- a/package.json +++ b/package.json @@ -998,10 +998,15 @@ "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.showOIDCDeviceAuthFlow": { + "type": "boolean", + "default": false, + "description": "Show a checkbox on the connection form to enable device auth flow authentication for MongoDB server OIDC Authentication. This enables a less secure authentication flow that can be used as a fallback when browser-based authentication is unavailable." + }, "mdb.sendTelemetry": { "type": "boolean", "default": true, - "description": "Allow the collection of anonynous diagnostic and usage telemetry data to help improve the product." + "description": "Allow the collection of anonymous diagnostic and usage telemetry data to help improve the product." }, "mdb.connectionSaving.hideOptionToChooseWhereToSaveNewConnections": { "type": "boolean", diff --git a/src/connectionController.ts b/src/connectionController.ts index ca65bdb68..a661f2ffd 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -67,6 +67,46 @@ type RecursivePartial = { : T[P]; }; +function isOIDCAuth(connectionString: string): boolean { + const authMechanismString = ( + new ConnectionString(connectionString).searchParams.get('authMechanism') || + '' + ).toUpperCase(); + + return authMechanismString === 'MONGODB-OIDC'; +} + +// Exported for testing. +export function getNotifyDeviceFlowForConnectionAttempt( + connectionOptions: ConnectionOptions +) { + const isOIDCConnectionAttempt = isOIDCAuth( + connectionOptions.connectionString + ); + let notifyDeviceFlow: + | ((deviceFlowInformation: { + verificationUrl: string; + userCode: string; + }) => void) + | undefined; + + if (isOIDCConnectionAttempt) { + notifyDeviceFlow = ({ + verificationUrl, + userCode, + }: { + verificationUrl: string; + userCode: string; + }) => { + void vscode.window.showInformationMessage( + `Visit the following URL to complete authentication: ${verificationUrl} Enter the following code on that page: ${userCode}` + ); + }; + } + + return notifyDeviceFlow; +} + export default class ConnectionController { // This is a map of connection ids to their configurations. // These connections can be saved on the session (runtime), @@ -265,6 +305,7 @@ export default class ConnectionController { return this._connect(savedConnectionWithoutSecrets.id, connectionType); } + // eslint-disable-next-line complexity async _connect( connectionId: string, connectionType: ConnectionTypes @@ -317,10 +358,14 @@ export default class ConnectionController { let dataService; try { + const notifyDeviceFlow = getNotifyDeviceFlowForConnectionAttempt( + connectionInfo.connectionOptions + ); + const connectionOptions = adjustConnectionOptionsBeforeConnect({ connectionOptions: connectionInfo.connectionOptions, defaultAppName: packageJSON.name, - notifyDeviceFlow: undefined, + notifyDeviceFlow, preferences: { forceConnectionOptions: [], browserCommandForOIDCAuth: undefined, // We overwrite this below. diff --git a/src/test/suite/connectionController.test.ts b/src/test/suite/connectionController.test.ts index fef2704f0..91efc89a2 100644 --- a/src/test/suite/connectionController.test.ts +++ b/src/test/suite/connectionController.test.ts @@ -5,9 +5,11 @@ import * as vscode from 'vscode'; import { afterEach, beforeEach } from 'mocha'; import assert from 'assert'; import * as mongodbDataService from 'mongodb-data-service'; +import ConnectionString from 'mongodb-connection-string-url'; import ConnectionController, { DataServiceEventTypes, + getNotifyDeviceFlowForConnectionAttempt, } from '../../connectionController'; import formatError from '../../utils/formatError'; import { StorageController, StorageVariables } from '../../storage'; @@ -53,10 +55,14 @@ suite('Connection Controller Test Suite', function () { telemetryService: testTelemetryService, }); let showErrorMessageStub: SinonStub; + let showInformationMessageStub: SinonStub; const sandbox = sinon.createSandbox(); beforeEach(() => { - sandbox.stub(vscode.window, 'showInformationMessage'); + showInformationMessageStub = sandbox.stub( + vscode.window, + 'showInformationMessage' + ); sandbox.stub(testTelemetryService, 'trackNewConnection'); showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage'); }); @@ -462,6 +468,45 @@ suite('Connection Controller Test Suite', function () { assert.strictEqual(JSON.stringify(workspaceStoreConnections), objectString); }); + test('getNotifyDeviceFlowForConnectionAttempt returns a function that shows a message with the url when oidc is set', function () { + const expectedUndefinedDeviceFlow = getNotifyDeviceFlowForConnectionAttempt( + { + connectionString: TEST_DATABASE_URI, + } + ); + + assert.strictEqual(expectedUndefinedDeviceFlow, undefined); + + const oidcConnectionString = new ConnectionString(TEST_DATABASE_URI); + oidcConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC'); + + const expectedFunction = getNotifyDeviceFlowForConnectionAttempt({ + connectionString: oidcConnectionString.toString(), + }); + assert.notStrictEqual(expectedFunction, undefined); + assert.strictEqual(showInformationMessageStub.called, false); + + ( + expectedFunction as (deviceFlowInformation: { + verificationUrl: string; + userCode: string; + }) => void + )({ + verificationUrl: 'test123', + userCode: 'testabc', + }); + + assert.strictEqual(showInformationMessageStub.called, true); + assert.strictEqual( + showInformationMessageStub.firstCall.args[0].includes('test123'), + true + ); + assert.strictEqual( + showInformationMessageStub.firstCall.args[0].includes('testabc'), + true + ); + }); + test('when a connection is removed it is also removed from workspace store', async () => { await testConnectionController.loadSavedConnections(); await vscode.workspace diff --git a/src/test/suite/views/webviewController.test.ts b/src/test/suite/views/webviewController.test.ts index 1231abc2a..040fc76f8 100644 --- a/src/test/suite/views/webviewController.test.ts +++ b/src/test/suite/views/webviewController.test.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import * as vscode from 'vscode'; import assert from 'assert'; -import { beforeEach, afterEach } from 'mocha'; +import { before, after, beforeEach, afterEach } from 'mocha'; import fs from 'fs'; import path from 'path'; @@ -127,6 +127,66 @@ suite('Webview Test Suite', () => { ); }); + test('web view content sets the oidc device auth id globally', () => { + const fakeWebview: any = { + asWebviewUri: (jsUri) => { + return jsUri; + }, + }; + + const extensionPath = mdbTestExtension.extensionContextStub.extensionPath; + const htmlString = getWebviewContent({ + extensionPath, + telemetryUserId: 'test', + webview: fakeWebview, + }); + + assert( + htmlString.includes( + ">window['VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID'] = false;" + ) + ); + }); + + suite('when oidc device auth flow setting is enabled', function () { + let originalDeviceAuthFlow; + before(async function () { + originalDeviceAuthFlow = vscode.workspace.getConfiguration( + 'mdb.showOIDCDeviceAuthFlow' + ); + + await vscode.workspace + .getConfiguration('mdb') + .update('showOIDCDeviceAuthFlow', true); + }); + after(async function () { + await vscode.workspace + .getConfiguration('mdb') + .update('showOIDCDeviceAuthFlow', originalDeviceAuthFlow); + }); + + test('web view content sets the oidc device auth id globally', () => { + const fakeWebview: any = { + asWebviewUri: (jsUri) => { + return jsUri; + }, + }; + + const extensionPath = mdbTestExtension.extensionContextStub.extensionPath; + const htmlString = getWebviewContent({ + extensionPath, + telemetryUserId: 'test', + webview: fakeWebview, + }); + + assert( + htmlString.includes( + ">window['VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID'] = true;" + ) + ); + }); + }); + test('web view listens for a connect message and adds the connection', (done) => { const extensionContextStub = new ExtensionContextStub(); const testStorageController = new StorageController(extensionContextStub); diff --git a/src/views/webview-app/connection-form.tsx b/src/views/webview-app/connection-form.tsx index 6bdbc5f26..f13d329ed 100644 --- a/src/views/webview-app/connection-form.tsx +++ b/src/views/webview-app/connection-form.tsx @@ -10,6 +10,7 @@ import { useDarkMode, } from '@mongodb-js/compass-components'; import { v4 as uuidv4 } from 'uuid'; +import { VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID } from './extension-app-message-constants'; const modalContentStyles = css({ // Override LeafyGreen width to accommodate the strict connection-form size. @@ -103,7 +104,8 @@ const ConnectionForm: React.FunctionComponent<{ protectConnectionStrings: false, forceConnectionOptions: [], showKerberosPasswordField: false, - showOIDCDeviceAuthFlow: false, + showOIDCDeviceAuthFlow: + window[VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID], enableOidc: true, enableDebugUseCsfleSchemaMap: false, protectConnectionStringsForNewConnections: false, diff --git a/src/views/webview-app/extension-app-message-constants.ts b/src/views/webview-app/extension-app-message-constants.ts index d17c40e3c..b526ffa63 100644 --- a/src/views/webview-app/extension-app-message-constants.ts +++ b/src/views/webview-app/extension-app-message-constants.ts @@ -11,6 +11,9 @@ export enum CONNECTION_STATUS { export const VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID = 'VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID'; +export const VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID = + 'VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID'; + export enum MESSAGE_TYPES { CONNECT = 'CONNECT', CANCEL_CONNECT = 'CANCEL_CONNECT', diff --git a/src/views/webviewController.ts b/src/views/webviewController.ts index 7c4966aaf..e29a453df 100644 --- a/src/views/webviewController.ts +++ b/src/views/webviewController.ts @@ -10,6 +10,7 @@ import EXTENSION_COMMANDS from '../commands'; import type { MESSAGE_FROM_WEBVIEW_TO_EXTENSION } from './webview-app/extension-app-message-constants'; import { MESSAGE_TYPES, + VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID, VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID, } from './webview-app/extension-app-message-constants'; import { openLink } from '../utils/linkHelper'; @@ -48,6 +49,10 @@ export const getWebviewContent = ({ // Use a nonce to only allow specific scripts to be run. const nonce = getNonce(); + const showOIDCDeviceAuthFlow = vscode.workspace + .getConfiguration('mdb') + .get('showOIDCDeviceAuthFlow'); + return ` @@ -63,6 +68,9 @@ export const getWebviewContent = ({
${getFeatureFlagsScript(nonce)} + `;