Skip to content

Commit 82de91d

Browse files
authored
feat(connect-form): add OIDC device auth flow with preference VSCODE-503 (#658)
1 parent ac1a000 commit 82de91d

File tree

8 files changed

+174
-5
lines changed

8 files changed

+174
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ If you use Terraform to manage your infrastructure, MongoDB for VS Code helps yo
8181
| `mdb.confirmRunAll` | Show a confirmation message before running commands in a playground. | `true` |
8282
| `mdb.confirmDeleteDocument` | Show a confirmation message before deleting a document in the tree view. | `true` |
8383
| `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` |
84+
| `mdb.showOIDCDeviceAuthFlow` | Opt-in and opt-out for diagnostic and telemetry collection. | `true` |
8485
| `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) |
8586
| `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` |
8687
| `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` |

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,10 +998,15 @@
998998
"default": true,
999999
"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."
10001000
},
1001+
"mdb.showOIDCDeviceAuthFlow": {
1002+
"type": "boolean",
1003+
"default": false,
1004+
"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."
1005+
},
10011006
"mdb.sendTelemetry": {
10021007
"type": "boolean",
10031008
"default": true,
1004-
"description": "Allow the collection of anonynous diagnostic and usage telemetry data to help improve the product."
1009+
"description": "Allow the collection of anonymous diagnostic and usage telemetry data to help improve the product."
10051010
},
10061011
"mdb.connectionSaving.hideOptionToChooseWhereToSaveNewConnections": {
10071012
"type": "boolean",

src/connectionController.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,46 @@ type RecursivePartial<T> = {
6767
: T[P];
6868
};
6969

70+
function isOIDCAuth(connectionString: string): boolean {
71+
const authMechanismString = (
72+
new ConnectionString(connectionString).searchParams.get('authMechanism') ||
73+
''
74+
).toUpperCase();
75+
76+
return authMechanismString === 'MONGODB-OIDC';
77+
}
78+
79+
// Exported for testing.
80+
export function getNotifyDeviceFlowForConnectionAttempt(
81+
connectionOptions: ConnectionOptions
82+
) {
83+
const isOIDCConnectionAttempt = isOIDCAuth(
84+
connectionOptions.connectionString
85+
);
86+
let notifyDeviceFlow:
87+
| ((deviceFlowInformation: {
88+
verificationUrl: string;
89+
userCode: string;
90+
}) => void)
91+
| undefined;
92+
93+
if (isOIDCConnectionAttempt) {
94+
notifyDeviceFlow = ({
95+
verificationUrl,
96+
userCode,
97+
}: {
98+
verificationUrl: string;
99+
userCode: string;
100+
}) => {
101+
void vscode.window.showInformationMessage(
102+
`Visit the following URL to complete authentication: ${verificationUrl} Enter the following code on that page: ${userCode}`
103+
);
104+
};
105+
}
106+
107+
return notifyDeviceFlow;
108+
}
109+
70110
export default class ConnectionController {
71111
// This is a map of connection ids to their configurations.
72112
// These connections can be saved on the session (runtime),
@@ -265,6 +305,7 @@ export default class ConnectionController {
265305
return this._connect(savedConnectionWithoutSecrets.id, connectionType);
266306
}
267307

308+
// eslint-disable-next-line complexity
268309
async _connect(
269310
connectionId: string,
270311
connectionType: ConnectionTypes
@@ -317,10 +358,14 @@ export default class ConnectionController {
317358

318359
let dataService;
319360
try {
361+
const notifyDeviceFlow = getNotifyDeviceFlowForConnectionAttempt(
362+
connectionInfo.connectionOptions
363+
);
364+
320365
const connectionOptions = adjustConnectionOptionsBeforeConnect({
321366
connectionOptions: connectionInfo.connectionOptions,
322367
defaultAppName: packageJSON.name,
323-
notifyDeviceFlow: undefined,
368+
notifyDeviceFlow,
324369
preferences: {
325370
forceConnectionOptions: [],
326371
browserCommandForOIDCAuth: undefined, // We overwrite this below.

src/test/suite/connectionController.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import * as vscode from 'vscode';
55
import { afterEach, beforeEach } from 'mocha';
66
import assert from 'assert';
77
import * as mongodbDataService from 'mongodb-data-service';
8+
import ConnectionString from 'mongodb-connection-string-url';
89

910
import ConnectionController, {
1011
DataServiceEventTypes,
12+
getNotifyDeviceFlowForConnectionAttempt,
1113
} from '../../connectionController';
1214
import formatError from '../../utils/formatError';
1315
import { StorageController, StorageVariables } from '../../storage';
@@ -53,10 +55,14 @@ suite('Connection Controller Test Suite', function () {
5355
telemetryService: testTelemetryService,
5456
});
5557
let showErrorMessageStub: SinonStub;
58+
let showInformationMessageStub: SinonStub;
5659
const sandbox = sinon.createSandbox();
5760

5861
beforeEach(() => {
59-
sandbox.stub(vscode.window, 'showInformationMessage');
62+
showInformationMessageStub = sandbox.stub(
63+
vscode.window,
64+
'showInformationMessage'
65+
);
6066
sandbox.stub(testTelemetryService, 'trackNewConnection');
6167
showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage');
6268
});
@@ -462,6 +468,45 @@ suite('Connection Controller Test Suite', function () {
462468
assert.strictEqual(JSON.stringify(workspaceStoreConnections), objectString);
463469
});
464470

471+
test('getNotifyDeviceFlowForConnectionAttempt returns a function that shows a message with the url when oidc is set', function () {
472+
const expectedUndefinedDeviceFlow = getNotifyDeviceFlowForConnectionAttempt(
473+
{
474+
connectionString: TEST_DATABASE_URI,
475+
}
476+
);
477+
478+
assert.strictEqual(expectedUndefinedDeviceFlow, undefined);
479+
480+
const oidcConnectionString = new ConnectionString(TEST_DATABASE_URI);
481+
oidcConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC');
482+
483+
const expectedFunction = getNotifyDeviceFlowForConnectionAttempt({
484+
connectionString: oidcConnectionString.toString(),
485+
});
486+
assert.notStrictEqual(expectedFunction, undefined);
487+
assert.strictEqual(showInformationMessageStub.called, false);
488+
489+
(
490+
expectedFunction as (deviceFlowInformation: {
491+
verificationUrl: string;
492+
userCode: string;
493+
}) => void
494+
)({
495+
verificationUrl: 'test123',
496+
userCode: 'testabc',
497+
});
498+
499+
assert.strictEqual(showInformationMessageStub.called, true);
500+
assert.strictEqual(
501+
showInformationMessageStub.firstCall.args[0].includes('test123'),
502+
true
503+
);
504+
assert.strictEqual(
505+
showInformationMessageStub.firstCall.args[0].includes('testabc'),
506+
true
507+
);
508+
});
509+
465510
test('when a connection is removed it is also removed from workspace store', async () => {
466511
await testConnectionController.loadSavedConnections();
467512
await vscode.workspace

src/test/suite/views/webviewController.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sinon from 'sinon';
22
import * as vscode from 'vscode';
33
import assert from 'assert';
4-
import { beforeEach, afterEach } from 'mocha';
4+
import { before, after, beforeEach, afterEach } from 'mocha';
55
import fs from 'fs';
66
import path from 'path';
77

@@ -127,6 +127,66 @@ suite('Webview Test Suite', () => {
127127
);
128128
});
129129

130+
test('web view content sets the oidc device auth id globally', () => {
131+
const fakeWebview: any = {
132+
asWebviewUri: (jsUri) => {
133+
return jsUri;
134+
},
135+
};
136+
137+
const extensionPath = mdbTestExtension.extensionContextStub.extensionPath;
138+
const htmlString = getWebviewContent({
139+
extensionPath,
140+
telemetryUserId: 'test',
141+
webview: fakeWebview,
142+
});
143+
144+
assert(
145+
htmlString.includes(
146+
">window['VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID'] = false;"
147+
)
148+
);
149+
});
150+
151+
suite('when oidc device auth flow setting is enabled', function () {
152+
let originalDeviceAuthFlow;
153+
before(async function () {
154+
originalDeviceAuthFlow = vscode.workspace.getConfiguration(
155+
'mdb.showOIDCDeviceAuthFlow'
156+
);
157+
158+
await vscode.workspace
159+
.getConfiguration('mdb')
160+
.update('showOIDCDeviceAuthFlow', true);
161+
});
162+
after(async function () {
163+
await vscode.workspace
164+
.getConfiguration('mdb')
165+
.update('showOIDCDeviceAuthFlow', originalDeviceAuthFlow);
166+
});
167+
168+
test('web view content sets the oidc device auth id globally', () => {
169+
const fakeWebview: any = {
170+
asWebviewUri: (jsUri) => {
171+
return jsUri;
172+
},
173+
};
174+
175+
const extensionPath = mdbTestExtension.extensionContextStub.extensionPath;
176+
const htmlString = getWebviewContent({
177+
extensionPath,
178+
telemetryUserId: 'test',
179+
webview: fakeWebview,
180+
});
181+
182+
assert(
183+
htmlString.includes(
184+
">window['VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID'] = true;"
185+
)
186+
);
187+
});
188+
});
189+
130190
test('web view listens for a connect message and adds the connection', (done) => {
131191
const extensionContextStub = new ExtensionContextStub();
132192
const testStorageController = new StorageController(extensionContextStub);

src/views/webview-app/connection-form.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useDarkMode,
1111
} from '@mongodb-js/compass-components';
1212
import { v4 as uuidv4 } from 'uuid';
13+
import { VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID } from './extension-app-message-constants';
1314

1415
const modalContentStyles = css({
1516
// Override LeafyGreen width to accommodate the strict connection-form size.
@@ -103,7 +104,8 @@ const ConnectionForm: React.FunctionComponent<{
103104
protectConnectionStrings: false,
104105
forceConnectionOptions: [],
105106
showKerberosPasswordField: false,
106-
showOIDCDeviceAuthFlow: false,
107+
showOIDCDeviceAuthFlow:
108+
window[VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID],
107109
enableOidc: true,
108110
enableDebugUseCsfleSchemaMap: false,
109111
protectConnectionStringsForNewConnections: false,

src/views/webview-app/extension-app-message-constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export enum CONNECTION_STATUS {
1111
export const VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID =
1212
'VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID';
1313

14+
export const VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID =
15+
'VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID';
16+
1417
export enum MESSAGE_TYPES {
1518
CONNECT = 'CONNECT',
1619
CANCEL_CONNECT = 'CANCEL_CONNECT',

src/views/webviewController.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import EXTENSION_COMMANDS from '../commands';
1010
import type { MESSAGE_FROM_WEBVIEW_TO_EXTENSION } from './webview-app/extension-app-message-constants';
1111
import {
1212
MESSAGE_TYPES,
13+
VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID,
1314
VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID,
1415
} from './webview-app/extension-app-message-constants';
1516
import { openLink } from '../utils/linkHelper';
@@ -48,6 +49,10 @@ export const getWebviewContent = ({
4849
// Use a nonce to only allow specific scripts to be run.
4950
const nonce = getNonce();
5051

52+
const showOIDCDeviceAuthFlow = vscode.workspace
53+
.getConfiguration('mdb')
54+
.get('showOIDCDeviceAuthFlow');
55+
5156
return `<!DOCTYPE html>
5257
<html lang="en">
5358
<head>
@@ -63,6 +68,9 @@ export const getWebviewContent = ({
6368
<div id="root"></div>
6469
${getFeatureFlagsScript(nonce)}
6570
<script nonce="${nonce}">window['${VSCODE_EXTENSION_SEGMENT_ANONYMOUS_ID}'] = '${telemetryUserId}';</script>
71+
<script nonce="${nonce}">window['${VSCODE_EXTENSION_OIDC_DEVICE_AUTH_ID}'] = ${
72+
showOIDCDeviceAuthFlow ? 'true' : 'false'
73+
};</script>
6674
<script nonce="${nonce}" src="${jsAppFileUrl}"></script>
6775
</body>
6876
</html>`;

0 commit comments

Comments
 (0)