Skip to content

Commit

Permalink
feat(connect-form): add OIDC device auth flow with preference VSCODE-503
Browse files Browse the repository at this point in the history
 (#658)
  • Loading branch information
Anemy authored Jan 16, 2024
1 parent ac1a000 commit 82de91d
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 46 additions & 1 deletion src/connectionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,46 @@ type RecursivePartial<T> = {
: 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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion src/test/suite/connectionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion src/test/suite/views/webviewController.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/views/webview-app/connection-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/views/webview-app/extension-app-message-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/views/webviewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -63,6 +68,9 @@ export const getWebviewContent = ({
<div id="root"></div>
${getFeatureFlagsScript(nonce)}
<script nonce="${nonce}">window['${VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID}'] = '${telemetryUserId}';</script>
<script nonce="${nonce}">window['${VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID}'] = ${
showOIDCDeviceAuthFlow ? 'true' : 'false'
};</script>
<script nonce="${nonce}" src="${jsAppFileUrl}"></script>
</body>
</html>`;
Expand Down

0 comments on commit 82de91d

Please sign in to comment.