Skip to content

Commit 222dddd

Browse files
bminglesmofojed
andauthored
feat: DH-18583: Saml Login (#222)
DH-18583: Saml Login ### Test Plan You can use https://jxn-saml-sanluis.int.illumon.com:8123/ for testing Google SAML login (Note that Python isn't currently configured on the server, so you'll have to use Groovy) e.g. ```json { "url": "https://jxn-feature-test-saml.int.illumon.com:8123/", "experimentalWorkerConfig": { "heapSize": 0.5, "scriptLanguage": "Groovy" } }, ``` Clicking on the DH server node for in VS Code for this server should now prompt with the option to login with `Google` or `Basic Login`. Google login should work in the following scenarios. - Logged out of Google - should prompt to login. Once logged in should redirect to VS Code and start a worker in the Connections tree - Logged in with 1 user - should automatically redirect without requiring login and create worker - Logged in with multiple users - should redirect to user picker in Google. Pick DH / illumon user, should redirect back to VS code and create worker. Basic Login should work as it always has. You can use iris user on this server. --------- Co-authored-by: Mike Bender <mikebender@deephaven.io>
1 parent 54ebd36 commit 222dddd

23 files changed

+857
-54
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Enterprise servers can be configured via the `"deephaven.enterpriseServers"` set
5858

5959
![Enterprise Server Settings](./docs/assets/dhe-settings.gif)
6060

61+
For information on how to authenticate with enterprise servers, see [Enterprise Authentication](docs/enterprise-auth.md).
62+
6163
## SSL Certificates
6264
Deephaven servers using self-signed certificates or internal CA's will require configuring VS Code to trust the signing certificate.
6365

docs/assets/dhe-basic-auth.gif

487 KB
Loading

docs/assets/dhe-generate-keypair.gif

561 KB
Loading

docs/assets/dhe-keypair-auth.gif

495 KB
Loading

docs/assets/dhe-saml-auth.gif

649 KB
Loading

docs/enterprise-auth.md

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Deephaven VS Code - Enterprise Authentication
2+
The Deephaven VS Code extension supports multiple authentication methods for Enterprise servers.
3+
* Basic Login
4+
* Private / public key pair
5+
* SAML based single sign-on
6+
7+
## Basic Login
8+
By default, the extension will accept a basic username / password login to authenticate with a Deephaven Enterprise server. To initiate a login, click on a running server node in the servers list or run a script to initiate a connection.
9+
10+
![Enterprise Basic Auth](assets/dhe-basic-auth.gif)
11+
12+
## Private / Public Key Pair Login
13+
The Deephaven VS Code extension supports generating a private / public key pair that can be used to authenticate with a Deephaven Enterprise server. A generated private key will be stored locally by the extension, and the corresponding public key will be stored on the Deephaven server associated with a username.
14+
15+
### Generating Key Pair
16+
To generate a key pair:
17+
* Right click on a running server node in the server list and click "Generate DHE Key Pair".
18+
* You will be prompted to login with the username / password you would like to associate the key pair with.
19+
* On successful login, the generated public key will be uploaded to the server and associated with the username.
20+
21+
![Generate Enterprise Key Pair](assets/dhe-generate-keypair.gif)
22+
23+
After creating the key pair, clicking on the server node should prompt for a username. If you enter a username associated with a stored key pair, you will be able to login without a password.
24+
25+
![Enterprise Key Pair Login](assets/dhe-keypair-auth.gif)
26+
27+
### Deleting a Key Pair
28+
To delete all Deephaven private keys managed by the extension from your local machine, you can type "Deephaven: Clear Secrets" in the VS Code command palette. Note that this action is irreversible, but it is easy to regenerate a key pair for any server you still want to keep access to.
29+
30+
### Single Sign-On
31+
Deephaven Enterprise servers can be configured for single sign-on (SSO) using a Security Assertion Markup Language (SAML) identity provider. In order to support the necessary login redirects, the `authentication.samlauth.jetty.redirect.list` server prop will need to include `vscode://deephaven.vscode-deephaven/*`. The VS Code extension will automatically detect what kind of authentication is supported by a Deephaven server. If multiple options are available, the extension will prompt you to chose which one to use.
32+
33+
![Enterprise SAML Auth](assets/dhe-saml-auth.gif)
34+
35+
If a SAML login flow is initiated, you will be prompted a few times to step through the auth flow and to login to the configured identity provider in the browser. Once complete, the browser should redirect to VS Code with an active connection to the server.

releases/vscode-deephaven-latest.vsix

4.92 MB
Binary file not shown.

src/common/constants.ts

+14
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const ICON_ID = {
7777
disconnected: 'plug',
7878
runAll: 'run-all',
7979
runSelection: 'run',
80+
saml: 'shield',
8081
server: 'server',
8182
serverConnected: 'circle-large-filled',
8283
serverRunning: 'circle-large-outline',
@@ -156,3 +157,16 @@ ${REQUIREMENTS_TABLE_NAME} = new_table([
156157
string_col("${REQUIREMENTS_TABLE_NAME_COLUMN_NAME}", list(installed)),
157158
string_col("${REQUIREMENTS_TABLE_VERSION_COLUMN_NAME}", [version(pkg) for pkg in installed])
158159
])` as const;
160+
161+
export const AUTH_CONFIG_PASSWORDS_ENABLED =
162+
'authentication.passwordsEnabled' as const;
163+
export const AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH =
164+
'authentication.client.customlogin.class.SAMLAuth' as const;
165+
export const AUTH_CONFIG_SAML_PROVIDER_NAME =
166+
'authentication.client.samlauth.provider.name' as const;
167+
export const AUTH_CONFIG_SAML_LOGIN_URL =
168+
'authentication.client.samlauth.login.url' as const;
169+
170+
export const DH_SAML_AUTH_PROVIDER_TYPE = 'dhsaml' as const;
171+
export const DH_SAML_SERVER_URL_SCOPE_KEY = 'deephaven.samlServerUrl' as const;
172+
export const DH_SAML_LOGIN_URL_SCOPE_KEY = 'deephaven.samlLoginUrl' as const;

src/controllers/ExtensionController.ts

+10
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
ServerConnectionPanelTreeProvider,
4949
runSelectedLinesHoverProvider,
5050
RunMarkdownCodeBlockCodeLensProvider,
51+
SamlAuthProvider,
5152
} from '../providers';
5253
import {
5354
DheJsApiCache,
@@ -183,6 +184,7 @@ export class ExtensionController implements Disposable {
183184
this.initializeCodeLenses();
184185
this.initializeHoverProviders();
185186
this.initializeServerManager();
187+
this.initializeAuthProviders();
186188
this.initializeTempDirectory();
187189
this.initializeConnectionController();
188190
this.initializePanelController();
@@ -204,6 +206,14 @@ export class ExtensionController implements Disposable {
204206
logger.info(`Deactivating Deephaven extension`);
205207
};
206208

209+
/**
210+
* Initialize authentication providers.
211+
*/
212+
initializeAuthProviders = (): void => {
213+
const samlAuthProvider = new SamlAuthProvider(this._context);
214+
this._context.subscriptions.push(samlAuthProvider);
215+
};
216+
207217
/**
208218
* Initialize code lenses for running Deephaven code.
209219
*/

src/controllers/UserLoginController.ts

+77-38
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ import {
1616
CREATE_DHE_AUTHENTICATED_CLIENT_CMD,
1717
GENERATE_DHE_KEY_PAIR_CMD,
1818
} from '../common';
19-
import { Logger, promptForPsk, runUserLoginWorkflow } from '../util';
19+
import {
20+
Logger,
21+
promptForPsk,
22+
promptForAuthFlow,
23+
promptForCredentials,
24+
isMultiAuthConfig,
25+
getAuthFlow,
26+
isNoAuthConfig,
27+
} from '../util';
2028
import type {
2129
CoreAuthenticatedClient,
2230
CoreUnauthenticatedClient,
@@ -26,15 +34,17 @@ import type {
2634
ISecretService,
2735
IServerManager,
2836
IToastService,
37+
LoginPromptCredentials,
2938
ServerState,
3039
} from '../types';
31-
import { hasInteractivePermission } from '../dh/dhe';
40+
import { getDheAuthConfig, hasInteractivePermission } from '../dh/dhe';
3241
import {
3342
AUTH_HANDLER_TYPE_ANONYMOUS,
3443
AUTH_HANDLER_TYPE_DHE,
3544
AUTH_HANDLER_TYPE_PSK,
3645
loginClient,
3746
} from '../dh/dhc';
47+
import { SamlAuthProvider } from '../providers';
3848

3949
const logger = new Logger('UserLoginController');
4050

@@ -137,7 +147,7 @@ export class UserLoginController extends ControllerBase {
137147
const userLoginPreferences =
138148
await this.secretService.getUserLoginPreferences(serverUrl);
139149

140-
const credentials = await runUserLoginWorkflow({
150+
const credentials = await promptForCredentials({
141151
title,
142152
userLoginPreferences,
143153
});
@@ -272,49 +282,75 @@ export class UserLoginController extends ControllerBase {
272282
serverUrl: URL,
273283
operateAsAnotherUser: boolean
274284
): Promise<void> => {
275-
const title = 'Login';
285+
const dheClient = await this.dheClientFactory(serverUrl);
286+
const authConfig = await getDheAuthConfig(dheClient);
276287

277-
const secretKeys = await this.secretService.getServerKeys(serverUrl);
278-
const userLoginPreferences =
279-
await this.secretService.getUserLoginPreferences(serverUrl);
288+
if (isNoAuthConfig(authConfig)) {
289+
this.toast.info('No authentication methods configured.');
290+
return;
291+
}
280292

281-
const privateKeyUserNames = Object.keys(secretKeys) as Username[];
293+
const authFlow = isMultiAuthConfig(authConfig)
294+
? await promptForAuthFlow(authConfig)
295+
: getAuthFlow(authConfig);
282296

283-
const credentials = await runUserLoginWorkflow({
284-
title,
285-
userLoginPreferences,
286-
privateKeyUserNames,
287-
showOperatesAs: operateAsAnotherUser,
288-
});
289-
290-
// Cancelled by user
291-
if (credentials == null) {
297+
if (authFlow == null) {
292298
this.toast.info('Login cancelled.');
293299
return;
294300
}
295301

296-
const { username, operateAs = username } = credentials;
302+
let authenticatedClient: DheAuthenticatedClient;
303+
let credentials: LoginPromptCredentials | undefined = undefined;
297304

298-
await this.secretService.storeUserLoginPreferences(serverUrl, {
299-
lastLogin: username,
300-
operateAsUser: {
301-
...userLoginPreferences.operateAsUser,
302-
[username]: operateAs,
303-
},
304-
});
305+
try {
306+
if (authFlow.type === 'saml') {
307+
authenticatedClient = await SamlAuthProvider.runSamlLoginWorkflow(
308+
dheClient,
309+
serverUrl,
310+
authFlow.config
311+
);
312+
} else {
313+
const title = 'Login';
305314

306-
const dheClient = await this.dheClientFactory(serverUrl);
315+
const secretKeys = await this.secretService.getServerKeys(serverUrl);
316+
const userLoginPreferences =
317+
await this.secretService.getUserLoginPreferences(serverUrl);
307318

308-
try {
309-
const authenticatedClient =
310-
credentials.type === 'password'
311-
? await loginClientWithPassword(dheClient, credentials)
312-
: await loginClientWithKeyPair(dheClient, {
313-
...credentials,
314-
keyPair: (await this.secretService.getServerKeys(serverUrl))?.[
315-
username
316-
],
317-
});
319+
const privateKeyUserNames = Object.keys(secretKeys) as Username[];
320+
321+
credentials = await promptForCredentials({
322+
title,
323+
userLoginPreferences,
324+
privateKeyUserNames,
325+
showOperatesAs: operateAsAnotherUser,
326+
});
327+
328+
// Cancelled by user
329+
if (credentials == null) {
330+
this.toast.info('Login cancelled.');
331+
return;
332+
}
333+
334+
const { username, operateAs = username } = credentials;
335+
336+
await this.secretService.storeUserLoginPreferences(serverUrl, {
337+
lastLogin: username,
338+
operateAsUser: {
339+
...userLoginPreferences.operateAsUser,
340+
[username]: operateAs,
341+
},
342+
});
343+
344+
authenticatedClient =
345+
credentials.type === 'password'
346+
? await loginClientWithPassword(dheClient, credentials)
347+
: await loginClientWithKeyPair(dheClient, {
348+
...credentials,
349+
keyPair: (await this.secretService.getServerKeys(serverUrl))?.[
350+
username
351+
],
352+
});
353+
}
318354

319355
if (!(await hasInteractivePermission(authenticatedClient))) {
320356
throw new Error('User does not have interactive permissions.');
@@ -327,8 +363,11 @@ export class UserLoginController extends ControllerBase {
327363

328364
this.toast.error('Login failed. Please check your credentials.');
329365

330-
if (credentials.type === 'keyPair') {
331-
await this.secretService.deleteUserServerKeys(serverUrl, username);
366+
if (credentials?.type === 'keyPair') {
367+
await this.secretService.deleteUserServerKeys(
368+
serverUrl,
369+
credentials.username
370+
);
332371
}
333372
}
334373
};

src/dh/dhe.spec.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { EnterpriseClient } from '@deephaven-enterprise/jsapi-types';
3+
import {
4+
AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH,
5+
AUTH_CONFIG_PASSWORDS_ENABLED,
6+
AUTH_CONFIG_SAML_LOGIN_URL,
7+
AUTH_CONFIG_SAML_PROVIDER_NAME,
8+
} from '../common';
9+
import { getDheAuthConfig } from './dhe';
10+
11+
describe('getDheAuthConfig', () => {
12+
const given = {
13+
samlLoginClass: [
14+
AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH,
15+
'mock.loginClass',
16+
],
17+
samlProviderName: [AUTH_CONFIG_SAML_PROVIDER_NAME, 'mock.providerName'],
18+
samlLoginUrl: [AUTH_CONFIG_SAML_LOGIN_URL, 'mock.loginUrl'],
19+
passwordsEnabled: [AUTH_CONFIG_PASSWORDS_ENABLED, 'true'],
20+
passwordsDisabled: [AUTH_CONFIG_PASSWORDS_ENABLED, 'false'],
21+
} as const;
22+
23+
const givenSamlConfig = {
24+
full: [given.samlLoginClass, given.samlProviderName, given.samlLoginUrl],
25+
partial: [given.samlLoginClass, given.samlProviderName],
26+
} as const;
27+
28+
const expected = {
29+
samlConfigFull: {
30+
loginClass: given.samlLoginClass[1],
31+
providerName: given.samlProviderName[1],
32+
loginUrl: given.samlLoginUrl[1],
33+
},
34+
} as const;
35+
36+
it.each([
37+
[
38+
'Undefined passwords config, Full SAML config',
39+
givenSamlConfig.full,
40+
{
41+
isPasswordEnabled: true,
42+
samlConfig: expected.samlConfigFull,
43+
},
44+
],
45+
[
46+
'Undefined password config, Partial SAML config',
47+
givenSamlConfig.partial,
48+
{
49+
isPasswordEnabled: true,
50+
samlConfig: null,
51+
},
52+
],
53+
[
54+
'Passwords enabled config, Full SAML config',
55+
[given.passwordsEnabled, ...givenSamlConfig.full],
56+
{
57+
isPasswordEnabled: true,
58+
samlConfig: expected.samlConfigFull,
59+
},
60+
],
61+
[
62+
'Passwords disabled config, Full SAML config',
63+
[given.passwordsDisabled, ...givenSamlConfig.full],
64+
{
65+
isPasswordEnabled: false,
66+
samlConfig: expected.samlConfigFull,
67+
},
68+
],
69+
[
70+
'Passwords disabled config, Partial SAML config',
71+
[given.passwordsDisabled, ...givenSamlConfig.partial],
72+
{
73+
isPasswordEnabled: false,
74+
samlConfig: null,
75+
},
76+
],
77+
])('should return auth config: %s', async (_label, given, expected) => {
78+
const getAuthConfigValues = vi.fn().mockResolvedValue(given);
79+
const dheClient = { getAuthConfigValues } as unknown as EnterpriseClient;
80+
81+
const actual = await getDheAuthConfig(dheClient);
82+
expect(actual).toEqual(expected);
83+
});
84+
});

0 commit comments

Comments
 (0)