Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: DH-18583: Saml Login #222

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Enterprise servers can be configured via the `"deephaven.enterpriseServers"` set

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

For information on how to authenticate with enterprise servers, see [Enterprise Authentication](docs/enterprise-auth.md).

## SSL Certificates
Deephaven servers using self-signed certificates or internal CA's will require configuring VS Code to trust the signing certificate.

Expand Down
Binary file added docs/assets/dhe-basic-auth.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/dhe-generate-keypair.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/dhe-keypair-auth.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/dhe-saml-auth.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions docs/enterprise-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Deephaven VS Code - Enterprise Authentication
The Deephaven VS Code extension supports multiple authentication methods for Enterprise servers.
* Basic Login
* Private / public key pair
* SAML based single sign-on

## Basic Login
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.

![Enterprise Basic Auth](assets/dhe-basic-auth.gif)

## Private / Public Key Pair Login
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.

### Generating Key Pair
To generate a key pair:
* Right click on a running server node in the server list and click "Generate DHE Key Pair".
* You will be prompted to login with the username / password you would like to associate the key pair with.
* On successful login, the generated public key will be uploaded to the server and associated with the username.

![Generate Enterprise Key Pair](assets/dhe-generate-keypair.gif)

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.

![Enterprise Key Pair Login](assets/dhe-keypair-auth.gif)

### Deleting a Key Pair
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.

### SAML Single Sign-On
Deephaven Enterprise servers can be configured for Single sign-on using a SAML identity provider. 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.

![Enterprise SAML Auth](assets/dhe-saml-auth.gif)

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.
Binary file modified releases/vscode-deephaven-latest.vsix
Binary file not shown.
14 changes: 14 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const ICON_ID = {
disconnected: 'plug',
runAll: 'run-all',
runSelection: 'run',
saml: 'shield',
server: 'server',
serverConnected: 'circle-large-filled',
serverRunning: 'circle-large-outline',
Expand Down Expand Up @@ -156,3 +157,16 @@ ${REQUIREMENTS_TABLE_NAME} = new_table([
string_col("${REQUIREMENTS_TABLE_NAME_COLUMN_NAME}", list(installed)),
string_col("${REQUIREMENTS_TABLE_VERSION_COLUMN_NAME}", [version(pkg) for pkg in installed])
])` as const;

export const AUTH_CONFIG_PASSWORDS_ENABLED =
'authentication.passwordsEnabled' as const;
export const AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH =
'authentication.client.customlogin.class.SAMLAuth' as const;
export const AUTH_CONFIG_SAML_PROVIDER_NAME =
'authentication.client.samlauth.provider.name' as const;
export const AUTH_CONFIG_SAML_LOGIN_URL =
'authentication.client.samlauth.login.url' as const;

export const DH_SAML_AUTH_PROVIDER_TYPE = 'dhsaml';
export const DH_SAML_SERVER_URL_SCOPE_KEY = 'deephaven.samlServerUrl' as const;
export const DH_SAML_LOGIN_URL_SCOPE_KEY = 'deephaven.samlLoginUrl' as const;
10 changes: 10 additions & 0 deletions src/controllers/ExtensionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
ServerConnectionPanelTreeProvider,
runSelectedLinesHoverProvider,
RunMarkdownCodeBlockCodeLensProvider,
SamlAuthProvider,
} from '../providers';
import {
DheJsApiCache,
Expand Down Expand Up @@ -183,6 +184,7 @@ export class ExtensionController implements Disposable {
this.initializeCodeLenses();
this.initializeHoverProviders();
this.initializeServerManager();
this.initializeAuthProviders();
this.initializeTempDirectory();
this.initializeConnectionController();
this.initializePanelController();
Expand All @@ -204,6 +206,14 @@ export class ExtensionController implements Disposable {
logger.info(`Deactivating Deephaven extension`);
};

/**
* Initialize authentication providers.
*/
initializeAuthProviders = (): void => {
const samlAuthProvider = new SamlAuthProvider(this._context);
this._context.subscriptions.push(samlAuthProvider);
};

/**
* Initialize code lenses for running Deephaven code.
*/
Expand Down
115 changes: 77 additions & 38 deletions src/controllers/UserLoginController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ import {
CREATE_DHE_AUTHENTICATED_CLIENT_CMD,
GENERATE_DHE_KEY_PAIR_CMD,
} from '../common';
import { Logger, promptForPsk, runUserLoginWorkflow } from '../util';
import {
Logger,
promptForPsk,
promptForAuthFlow,
promptForCredentials,
isMultiAuthConfig,
getAuthFlow,
isNoAuthConfig,
} from '../util';
import type {
CoreAuthenticatedClient,
CoreUnauthenticatedClient,
Expand All @@ -26,15 +34,17 @@ import type {
ISecretService,
IServerManager,
IToastService,
LoginPromptCredentials,
ServerState,
} from '../types';
import { hasInteractivePermission } from '../dh/dhe';
import { getDheAuthConfig, hasInteractivePermission } from '../dh/dhe';
import {
AUTH_HANDLER_TYPE_ANONYMOUS,
AUTH_HANDLER_TYPE_DHE,
AUTH_HANDLER_TYPE_PSK,
loginClient,
} from '../dh/dhc';
import { SamlAuthProvider } from '../providers';

const logger = new Logger('UserLoginController');

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

const credentials = await runUserLoginWorkflow({
const credentials = await promptForCredentials({
title,
userLoginPreferences,
});
Expand Down Expand Up @@ -272,49 +282,75 @@ export class UserLoginController extends ControllerBase {
serverUrl: URL,
operateAsAnotherUser: boolean
): Promise<void> => {
const title = 'Login';
const dheClient = await this.dheClientFactory(serverUrl);
const authConfig = await getDheAuthConfig(dheClient);

const secretKeys = await this.secretService.getServerKeys(serverUrl);
const userLoginPreferences =
await this.secretService.getUserLoginPreferences(serverUrl);
if (isNoAuthConfig(authConfig)) {
this.toast.info('No authentication methods configured.');
return;
}

const privateKeyUserNames = Object.keys(secretKeys) as Username[];
const authFlow = isMultiAuthConfig(authConfig)
? await promptForAuthFlow(authConfig)
: getAuthFlow(authConfig);

const credentials = await runUserLoginWorkflow({
title,
userLoginPreferences,
privateKeyUserNames,
showOperatesAs: operateAsAnotherUser,
});

// Cancelled by user
if (credentials == null) {
if (authFlow == null) {
this.toast.info('Login cancelled.');
return;
}

const { username, operateAs = username } = credentials;
let authenticatedClient: DheAuthenticatedClient | undefined = undefined;
let credentials: LoginPromptCredentials | undefined = undefined;

await this.secretService.storeUserLoginPreferences(serverUrl, {
lastLogin: username,
operateAsUser: {
...userLoginPreferences.operateAsUser,
[username]: operateAs,
},
});
try {
if (authFlow.type === 'saml') {
authenticatedClient = await SamlAuthProvider.runSamlLoginWorkflow(
dheClient,
serverUrl,
authFlow.config
);
} else {
const title = 'Login';

const dheClient = await this.dheClientFactory(serverUrl);
const secretKeys = await this.secretService.getServerKeys(serverUrl);
const userLoginPreferences =
await this.secretService.getUserLoginPreferences(serverUrl);

try {
const authenticatedClient =
credentials.type === 'password'
? await loginClientWithPassword(dheClient, credentials)
: await loginClientWithKeyPair(dheClient, {
...credentials,
keyPair: (await this.secretService.getServerKeys(serverUrl))?.[
username
],
});
const privateKeyUserNames = Object.keys(secretKeys) as Username[];

credentials = await promptForCredentials({
title,
userLoginPreferences,
privateKeyUserNames,
showOperatesAs: operateAsAnotherUser,
});

// Cancelled by user
if (credentials == null) {
this.toast.info('Login cancelled.');
return;
}

const { username, operateAs = username } = credentials;

await this.secretService.storeUserLoginPreferences(serverUrl, {
lastLogin: username,
operateAsUser: {
...userLoginPreferences.operateAsUser,
[username]: operateAs,
},
});

authenticatedClient =
credentials.type === 'password'
? await loginClientWithPassword(dheClient, credentials)
: await loginClientWithKeyPair(dheClient, {
...credentials,
keyPair: (await this.secretService.getServerKeys(serverUrl))?.[
username
],
});
}

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

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

if (credentials.type === 'keyPair') {
await this.secretService.deleteUserServerKeys(serverUrl, username);
if (credentials?.type === 'keyPair') {
await this.secretService.deleteUserServerKeys(
serverUrl,
credentials.username
);
}
}
};
Expand Down
84 changes: 84 additions & 0 deletions src/dh/dhe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
import type { EnterpriseClient } from '@deephaven-enterprise/jsapi-types';
import {
AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH,
AUTH_CONFIG_PASSWORDS_ENABLED,
AUTH_CONFIG_SAML_LOGIN_URL,
AUTH_CONFIG_SAML_PROVIDER_NAME,
} from '../common';
import { getDheAuthConfig } from './dhe';

describe('getDheAuthConfig', () => {
const given = {
samlLoginClass: [
AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH,
'mock.loginClass',
],
samlProviderName: [AUTH_CONFIG_SAML_PROVIDER_NAME, 'mock.providerName'],
samlLoginUrl: [AUTH_CONFIG_SAML_LOGIN_URL, 'mock.loginUrl'],
passwordsEnabled: [AUTH_CONFIG_PASSWORDS_ENABLED, 'true'],
passwordsDisabled: [AUTH_CONFIG_PASSWORDS_ENABLED, 'false'],
} as const;

const givenSamlConfig = {
full: [given.samlLoginClass, given.samlProviderName, given.samlLoginUrl],
partial: [given.samlLoginClass, given.samlProviderName],
} as const;

const expected = {
samlConfigFull: {
loginClass: given.samlLoginClass[1],
providerName: given.samlProviderName[1],
loginUrl: given.samlLoginUrl[1],
},
} as const;

it.each([
[
'Undefined passwords config, Full SAML config',
givenSamlConfig.full,
{
isPasswordEnabled: true,
samlConfig: expected.samlConfigFull,
},
],
[
'Undefined password config, Partial SAML config',
givenSamlConfig.partial,
{
isPasswordEnabled: true,
samlConfig: null,
},
],
[
'Passwords enabled config, Full SAML config',
[given.passwordsEnabled, ...givenSamlConfig.full],
{
isPasswordEnabled: true,
samlConfig: expected.samlConfigFull,
},
],
[
'Passwords disabled config, Full SAML config',
[given.passwordsDisabled, ...givenSamlConfig.full],
{
isPasswordEnabled: false,
samlConfig: expected.samlConfigFull,
},
],
[
'Passwords disabled config, Partial SAML config',
[given.passwordsDisabled, ...givenSamlConfig.partial],
{
isPasswordEnabled: false,
samlConfig: null,
},
],
])('should return auth config: %s', async (_label, given, expected) => {
const getAuthConfigValues = vi.fn().mockResolvedValue(given);
const dheClient = { getAuthConfigValues } as unknown as EnterpriseClient;

const actual = await getDheAuthConfig(dheClient);
expect(actual).toEqual(expected);
});
});
Loading