diff --git a/README.md b/README.md index 6ca42f00..1f66acff 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/assets/dhe-basic-auth.gif b/docs/assets/dhe-basic-auth.gif new file mode 100644 index 00000000..d3eb15e6 Binary files /dev/null and b/docs/assets/dhe-basic-auth.gif differ diff --git a/docs/assets/dhe-generate-keypair.gif b/docs/assets/dhe-generate-keypair.gif new file mode 100644 index 00000000..01b8c422 Binary files /dev/null and b/docs/assets/dhe-generate-keypair.gif differ diff --git a/docs/assets/dhe-keypair-auth.gif b/docs/assets/dhe-keypair-auth.gif new file mode 100644 index 00000000..63924599 Binary files /dev/null and b/docs/assets/dhe-keypair-auth.gif differ diff --git a/docs/assets/dhe-saml-auth.gif b/docs/assets/dhe-saml-auth.gif new file mode 100644 index 00000000..20635c13 Binary files /dev/null and b/docs/assets/dhe-saml-auth.gif differ diff --git a/docs/enterprise-auth.md b/docs/enterprise-auth.md new file mode 100644 index 00000000..eaf282c6 --- /dev/null +++ b/docs/enterprise-auth.md @@ -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. \ No newline at end of file diff --git a/releases/vscode-deephaven-latest.vsix b/releases/vscode-deephaven-latest.vsix index cd1b4c9d..5d5fd789 100644 Binary files a/releases/vscode-deephaven-latest.vsix and b/releases/vscode-deephaven-latest.vsix differ diff --git a/src/common/constants.ts b/src/common/constants.ts index 2a432104..7ab1e689 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -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', @@ -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; diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 06d96405..0b6e657a 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -48,6 +48,7 @@ import { ServerConnectionPanelTreeProvider, runSelectedLinesHoverProvider, RunMarkdownCodeBlockCodeLensProvider, + SamlAuthProvider, } from '../providers'; import { DheJsApiCache, @@ -183,6 +184,7 @@ export class ExtensionController implements Disposable { this.initializeCodeLenses(); this.initializeHoverProviders(); this.initializeServerManager(); + this.initializeAuthProviders(); this.initializeTempDirectory(); this.initializeConnectionController(); this.initializePanelController(); @@ -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. */ diff --git a/src/controllers/UserLoginController.ts b/src/controllers/UserLoginController.ts index 7350683a..f9a0f00d 100644 --- a/src/controllers/UserLoginController.ts +++ b/src/controllers/UserLoginController.ts @@ -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, @@ -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'); @@ -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, }); @@ -272,49 +282,75 @@ export class UserLoginController extends ControllerBase { serverUrl: URL, operateAsAnotherUser: boolean ): Promise => { - 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.'); @@ -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 + ); } } }; diff --git a/src/dh/dhe.spec.ts b/src/dh/dhe.spec.ts new file mode 100644 index 00000000..3fb6819a --- /dev/null +++ b/src/dh/dhe.spec.ts @@ -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); + }); +}); diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 5c48af80..1cf64733 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -2,6 +2,7 @@ import type { dh as DhcType } from '@deephaven/jsapi-types'; import type { EnterpriseDhType as DheType, EditableQueryInfo, + EnterpriseClient, QueryInfo, TypeSpecificFields, } from '@deephaven-enterprise/jsapi-types'; @@ -9,6 +10,7 @@ import { DraftQuery, QueryScheduler } from '@deephaven-enterprise/query-utils'; import type { AuthenticatedClient as DheAuthenticatedClient } from '@deephaven-enterprise/auth-nodejs'; import { hasStatusCode, loadModules } from '@deephaven/jsapi-nodejs'; import type { + AuthConfig, ConsoleType, GrpcURL, IdeURL, @@ -20,6 +22,10 @@ import type { WorkerURL, } from '../types'; import { + AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH, + AUTH_CONFIG_PASSWORDS_ENABLED, + AUTH_CONFIG_SAML_LOGIN_URL, + AUTH_CONFIG_SAML_PROVIDER_NAME, DEFAULT_TEMPORARY_QUERY_AUTO_TIMEOUT_MS, DEFAULT_TEMPORARY_QUERY_TIMEOUT_MS, INTERACTIVE_CONSOLE_QUERY_TYPE, @@ -228,6 +234,49 @@ export async function deleteQueries( await dheClient.deleteQueries(querySerials); } +/** + * Get auth config values from the DHE client. + * @param dheClient DHE client to use. + * @returns A promise that resolves to the auth config values. + */ +export async function getDheAuthConfig( + dheClient: EnterpriseClient +): Promise { + const authConfigMap = Object.fromEntries( + (await dheClient.getAuthConfigValues()).map(([key, value]) => [key, value]) + ); + + // Only consider SAML config if it is complete + const isSamlEnabled = + authConfigMap[AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH] && + authConfigMap[AUTH_CONFIG_SAML_PROVIDER_NAME] && + authConfigMap[AUTH_CONFIG_SAML_LOGIN_URL]; + + const samlConfig = isSamlEnabled + ? { + loginClass: authConfigMap[AUTH_CONFIG_CUSTOM_LOGIN_CLASS_SAML_AUTH], + providerName: authConfigMap[AUTH_CONFIG_SAML_PROVIDER_NAME], + loginUrl: authConfigMap[AUTH_CONFIG_SAML_LOGIN_URL], + } + : null; + + const authConfig: AuthConfig = { + // DH-16352 will be adding support to DH Web for disabling passwords. We + // already have a `authentication.passwordsEnabled` config prop on the + // server, so using that here. If for some reason DH-16352 takes another + // approach, we may need to update this. As-is, all that has to be done + // to disable password auth is: + // 1. Set `authentication.passwordsEnabled` prop to false on server. + // 2. Include `authentication.passwordsEnabled` in the list of values set in + // `authentication.client.configuration.list` prop to expose it to + // `client.getAuthConfigValues()`. + isPasswordEnabled: authConfigMap[AUTH_CONFIG_PASSWORDS_ENABLED] !== 'false', + samlConfig, + }; + + return authConfig; +} + /** * Get worker info from a query serial. * @param tagId Unique tag id to include in the worker info. diff --git a/src/providers/SamlAuthProvider.ts b/src/providers/SamlAuthProvider.ts new file mode 100644 index 00000000..9c27ae41 --- /dev/null +++ b/src/providers/SamlAuthProvider.ts @@ -0,0 +1,291 @@ +import * as vscode from 'vscode'; +import { + type AuthenticatedClient as DheAuthenticatedClient, + type UnauthenticatedClient, +} from '@deephaven-enterprise/auth-nodejs'; +import { + Logger, + makeSAMLSessionKey, + parseSamlScopes, + rejectAfterTimeout, + uniqueId, +} from '../util'; +import { + DH_SAML_AUTH_PROVIDER_TYPE, + DH_SAML_LOGIN_URL_SCOPE_KEY, + DH_SAML_SERVER_URL_SCOPE_KEY, +} from '../common'; +import { UriEventHandler, URLMap } from '../services'; +import { type Disposable, type SamlConfig, type UniqueID } from '../types'; + +const logger = new Logger('SamlAuthProvider'); + +export class SamlAuthProvider + implements vscode.AuthenticationProvider, vscode.Disposable +{ + /** + * Pending auth state is created before initiating the SAML login flow and + * used to verify the redirect response and to finalize the login once redirects + * have completed. + */ + private static pendingAuthState = new URLMap<{ + client: UnauthenticatedClient & Disposable; + stateId: UniqueID; + }>(); + + /** + * Run the SAML login workflow. + * @param dheClient The unauthenticated DHE client. + * @param serverUrl The server URL. + * @param config The SAML config. + * @returns The authenticated DHE client. + */ + static runSamlLoginWorkflow = async ( + dheClient: UnauthenticatedClient & Disposable, + serverUrl: URL, + config: SamlConfig + ): Promise => { + SamlAuthProvider.pendingAuthState.set(serverUrl, { + client: dheClient, + stateId: uniqueId(), + }); + + try { + await vscode.authentication.getSession( + DH_SAML_AUTH_PROVIDER_TYPE, + [ + `${DH_SAML_SERVER_URL_SCOPE_KEY}:${serverUrl.href}`, + `${DH_SAML_LOGIN_URL_SCOPE_KEY}:${config.loginUrl}`, + ], + { createIfNone: true } + ); + } finally { + SamlAuthProvider.pendingAuthState.delete(serverUrl); + } + + // If we get here, we know that `vscode.authentication.getSession` succeeded + // which means the client should be authenticated. + return dheClient as unknown as DheAuthenticatedClient; + }; + + /** + * The SAML auth flow will go through a series of redirects eventually sending + * a redirect to the vscode extension via the result of `createSamlRedirectUrl`. + * This method is responsible for validating the incoming request and verifying + * it has the expected state id as sent in the original `redirectUrl` to DH + * server. + * @param serverUrl The DH server URL this handler is for. + * @param uriEventHandler The uri event handler to use for handling the redirect. + * @param disposables An array of disposables to add the uri event handler to. + * @returns A promise that resolves to true if the uri event was handled successfully. + */ + static handleUriEventForServer = ( + serverUrl: URL, + uriEventHandler: UriEventHandler, + disposables: vscode.Disposable[] + ): Promise => { + return new Promise((resolve, reject) => { + uriEventHandler.event( + async uri => { + logger.debug('Handling uri:', uri.toString()); + + const state = SamlAuthProvider.pendingAuthState.get(serverUrl); + if (state == null) { + reject(`No state found for ${uri}.`); + return; + } + + const stateId = uri.path.substring(1); + if (stateId !== state.stateId) { + reject(`State id mismatch: ${stateId}`); + return; + } + + resolve(true); + }, + undefined, + disposables + ); + }); + }; + + constructor(context: vscode.ExtensionContext) { + this._context = context; + + this._disposable = vscode.Disposable.from( + this._onDidChangeSessions, + vscode.authentication.registerAuthenticationProvider( + DH_SAML_AUTH_PROVIDER_TYPE, + 'Deephaven SAML', + this, + { supportsMultipleAccounts: false } + ), + vscode.window.registerUriHandler(this._uriEventHandler), + SamlAuthProvider.pendingAuthState + ); + } + + private readonly _onDidChangeSessions = + new vscode.EventEmitter(); + + readonly onDidChangeSessions: vscode.Event = + this._onDidChangeSessions.event; + + private readonly _context: vscode.ExtensionContext; + private readonly _disposable: vscode.Disposable; + private readonly _uriEventHandler = new UriEventHandler(); + + /** + * Create a redirectUrl to send to the DH server as part of SAML auth flow. + * This will be used to redirect back to VS Code once the user has logged in. + * The `stateId` is encoded in the url as the path that can be extracted by + * a UriEventHandler to validate the redirect. + * Note that I attempted to pass this via a query param, but things were not + * getting encoded / decoded correctly on DH server. Simpler to just pass as + * the path. + * @param stateId + * @returns The redirect URL to send to the DH server. + */ + createSamlRedirectUrl(stateId: UniqueID): URL { + const publisher = this._context.extension.packageJSON.publisher; + const name = this._context.extension.packageJSON.name; + return new URL(`${vscode.env.uriScheme}://${publisher}.${name}/${stateId}`); + } + + dispose = (): void => { + this._disposable.dispose(); + }; + + /** + * Get a list of sessions. + * @param scopes An optional list of scopes. If provided, the sessions returned + * these scopes, otherwise all sessions should be returned. + * @returns A promise that resolves to an array of authentication sessions. + */ + getSessions = async ( + _scopes?: readonly string[] + ): Promise => { + // Not implementing this since there really isn't a concept of persistent + // SAML sessions that we can store in the extension. Once the user disconnects, + // there's no way to re-use the session since the extension only has access + // to the short lived nonce used to login. IdPs often store session info in + // browser, so user can just re-login, and if they have an active session, + // the browser will just pass it along without requiring credentials again. + return []; + }; + + /** + * Prompts a user to login. + * + * If login is successful, the onDidChangeSessions event should be fired. + * + * If login fails, a rejected promise should be returned. + * + * If the provider has specified that it does not support multiple accounts, + * then this should never be called if there is already an existing session + * matching these scopes. + * @param scopes A list of scopes that the new session should be created with. + * @returns A promise that resolves to an authentication session. + */ + createSession = async ( + scopes: readonly string[] + ): Promise => { + const samlScopes = parseSamlScopes(scopes); + if (samlScopes == null) { + throw new Error( + 'SAML authentication provider does not support this scope.' + ); + } + + logger.debug('createSession', samlScopes); + + const samlSessionKey = makeSAMLSessionKey(); + logger.debug('samlSessionKey:', `${samlSessionKey}`); + + const serverUrl = new URL(samlScopes.serverUrl); + const { stateId } = SamlAuthProvider.pendingAuthState.getOrThrow(serverUrl); + + const samlLoginUrl = new URL(samlScopes.samlLoginUrl); + samlLoginUrl.searchParams.append('key', samlSessionKey); + samlLoginUrl.searchParams.append( + 'redirect', + `${this.createSamlRedirectUrl(stateId)}` + ); + + const authSucceeded = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Signing in to Deephaven...', + cancellable: true, + }, + async (_, cancellationToken) => { + await vscode.env.openExternal( + vscode.Uri.parse(samlLoginUrl.toString()) + ); + + const disposables: vscode.Disposable[] = []; + + return Promise.race([ + SamlAuthProvider.handleUriEventForServer( + serverUrl, + this._uriEventHandler, + disposables + ), + rejectAfterTimeout(60000, 'Cancelled by timeout.'), + new Promise((_, reject) => + cancellationToken.onCancellationRequested( + () => reject('Cancelled by user.'), + undefined, + disposables + ) + ), + ]).finally(() => { + disposables.forEach(subscription => subscription.dispose()); + disposables.length = 0; + }); + } + ); + + if (!authSucceeded) { + throw new Error('Deephaven SAML authentication failed.'); + } + + const { client: dheClient } = + SamlAuthProvider.pendingAuthState.getOrThrow(serverUrl); + + try { + await dheClient.login({ + type: 'saml', + token: samlSessionKey, + }); + } catch (err) { + logger.error('Error during SAML login:', err); + throw err; + } + + const userInfo = await dheClient.getUserInfo(); + + const session: vscode.AuthenticationSession = { + id: uniqueId(), + // our "access token" is a short lived nonce, so no reason to hold on to it here + accessToken: '', + account: { + id: userInfo.username, + label: userInfo.username, + }, + scopes, + }; + + this._onDidChangeSessions.fire({ + added: [session], + removed: [], + changed: [], + }); + + return session; + }; + + removeSession = async (_sessionId: string): Promise => { + throw new Error('Method not implemented.'); + }; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 42c00aa7..be510f4e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ export * from './RunCommandCodeLensProvider'; export * from './RunMarkdownCodeBlockCodeLensProvider'; export * from './RunSelectedLinesHoverProvider'; +export * from './SamlAuthProvider'; export * from './ServerConnectionTreeProvider'; export * from './ServerTreeProvider'; export * from './ServerConnectionPanelTreeProvider'; diff --git a/src/services/UriEventHandler.ts b/src/services/UriEventHandler.ts new file mode 100644 index 00000000..b45a02f8 --- /dev/null +++ b/src/services/UriEventHandler.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; + +/** + * Uri event handler class that emits events when a URI is handled. This can be + * registered via `vscode.window.registerUriHandler` to handle incoming URI + * requests for Deephaven extension. + */ +export class UriEventHandler + extends vscode.EventEmitter + implements vscode.UriHandler +{ + public handleUri(uri: vscode.Uri): void { + this.fire(uri); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 3446998e..0f85e92f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -8,5 +8,6 @@ export * from './PollingService'; export * from './SecretService'; export * from './SerializedKeyMap'; export * from './ServerManager'; +export * from './UriEventHandler'; export * from './URIMap'; export * from './URLMap'; diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index bf5008e3..9d7a5336 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -58,11 +58,51 @@ export interface EnterpriseConnectionConfig { experimentalWorkerConfig?: WorkerConfig; } -export type LoginWorkflowType = 'login' | 'generatePrivateKey'; -export type LoginWorkflowResult = +export interface SamlConfig { + loginClass: string; + providerName: string; + loginUrl: string; +} + +export interface MultiAuthConfig { + isPasswordEnabled: true; + samlConfig: SamlConfig; +} + +// Mutually exclusive password or SAML auth config +export type SingleAuthConfig = + | { isPasswordEnabled: true; samlConfig: null } + | { isPasswordEnabled: false; samlConfig: SamlConfig }; + +export type NoAuthConfig = { + isPasswordEnabled: false; + samlConfig: null; +}; + +export type AuthConfig = MultiAuthConfig | SingleAuthConfig | NoAuthConfig; + +export interface PasswordAuthFlow { + type: 'password'; +} + +export interface SamlAuthFlow { + type: 'saml'; + config: SamlConfig; +} + +export type AuthFlow = PasswordAuthFlow | SamlAuthFlow; + +export interface SamlAuthScopes { + serverUrl: string; + samlLoginUrl: string; +} + +export type LoginPromptCredentials = | PasswordCredentials | Omit; +export type LoginWorkflowType = 'login' | 'generatePrivateKey'; + export type Psk = Brand<'Psk', string>; export type UserKeyPairs = Record; diff --git a/src/util/dataUtils.spec.ts b/src/util/dataUtils.spec.ts new file mode 100644 index 00000000..6ab82147 --- /dev/null +++ b/src/util/dataUtils.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { parseSamlScopes } from './dataUtils'; +import { + DH_SAML_LOGIN_URL_SCOPE_KEY, + DH_SAML_SERVER_URL_SCOPE_KEY, +} from '../common'; + +describe('parseSamlScopes', () => { + it('should return null if no SAML scopes are found', () => { + const scopes = ['scope1', 'scope2']; + const result = parseSamlScopes(scopes); + expect(result).toBeNull(); + }); + + it('should return the SAML scopes if found', () => { + const scopes = [ + `${DH_SAML_SERVER_URL_SCOPE_KEY}:https://someserver-url.com`, + `${DH_SAML_LOGIN_URL_SCOPE_KEY}:https://somelogin-url.com`, + ]; + const result = parseSamlScopes(scopes); + expect(result).toEqual({ + serverUrl: 'https://someserver-url.com', + samlLoginUrl: 'https://somelogin-url.com', + }); + }); +}); diff --git a/src/util/dataUtils.ts b/src/util/dataUtils.ts index ff526f31..cb1a85a2 100644 --- a/src/util/dataUtils.ts +++ b/src/util/dataUtils.ts @@ -1,4 +1,15 @@ -import type { NonEmptyArray } from '../types'; +import { + DH_SAML_LOGIN_URL_SCOPE_KEY, + DH_SAML_SERVER_URL_SCOPE_KEY, +} from '../common'; +import type { + AuthConfig, + AuthFlow, + MultiAuthConfig, + NoAuthConfig, + NonEmptyArray, + SingleAuthConfig, +} from '../types'; /** * Returns a date string formatted for use in a file path. @@ -29,6 +40,48 @@ export function hasProperty( return obj != null && typeof obj === 'object' && prop in obj; } +/** + * Get the authentication flow based on the authConfig. + * @param authConfig The authConfig to check. + * @returns The authentication flow + */ +export function getAuthFlow(authConfig: SingleAuthConfig): AuthFlow { + if (authConfig.isPasswordEnabled) { + return { + type: 'password', + }; + } + + return { + type: 'saml', + config: authConfig.samlConfig, + }; +} + +/** + * Type guard to check if the authConfig is a MultiAuthConfig. + * @param authConfig The authConfig to check. + * @returns true if the authConfig is a MultiAuthConfig, false otherwise + */ +export function isMultiAuthConfig( + authConfig: AuthConfig +): authConfig is MultiAuthConfig { + return authConfig.isPasswordEnabled && authConfig.samlConfig != null; +} + +/** + * Type guard to check if auth config has no authentication enabled. + * @param authConfig The authConfig to check. + * @returns true if the authConfig has no authentication enabled, false otherwise + */ +export function isNoAuthConfig( + authConfig: AuthConfig +): authConfig is NoAuthConfig { + return ( + authConfig.isPasswordEnabled === false && authConfig.samlConfig == null + ); +} + /** * Type guard to check if an array is non-empty. * @param array @@ -53,3 +106,32 @@ export function sortByStringProp( return String(a[propName]).localeCompare(String(b[propName])); }; } + +/** + * Parse SAML scopes from a given list of AuthenticationProvider scope strings. + * @param scopes The list of scopes to parse. + * @returns An object containing the server URL and SAML login URL if found, or null if not. + */ +export function parseSamlScopes(scopes: readonly string[]): { + serverUrl: string; + samlLoginUrl: string; +} | null { + const serverUrl = scopes.find(scope => + scope.startsWith(`${DH_SAML_SERVER_URL_SCOPE_KEY}:`) + ); + + const samlLoginUrl = scopes.find(scope => + scope.startsWith(`${DH_SAML_LOGIN_URL_SCOPE_KEY}:`) + ); + + if (serverUrl && samlLoginUrl) { + return { + serverUrl: serverUrl.substring(DH_SAML_SERVER_URL_SCOPE_KEY.length + 1), + samlLoginUrl: samlLoginUrl.substring( + DH_SAML_LOGIN_URL_SCOPE_KEY.length + 1 + ), + }; + } + + return null; +} diff --git a/src/util/idUtils.ts b/src/util/idUtils.ts index fb184b5b..b86aced3 100644 --- a/src/util/idUtils.ts +++ b/src/util/idUtils.ts @@ -13,3 +13,26 @@ const nanoidCustom = customAlphabet(urlAlphabet.replace('_', ''), 21); export function uniqueId(size: number = 21): UniqueID { return nanoidCustom(size) as UniqueID; } + +/* + * Create base-64 encoded key from a random string with the length no less than + * 96 (required by the DH authentication server). + */ +export function makeSAMLSessionKey(): string { + let key = ''; + for (let i = 0; i < 96; i += 1) { + key += String.fromCharCode(Math.floor(Math.random() * 255)); + } + + return ( + Buffer.from(key, 'binary') + .toString('base64') + // VS Code seems to aggressively encode Uris. This causes problems when + // opening the DH SAML login uri via `vscode.env.openExternal`. Specifically, + // `+` characters are replaced with `%2B` which gets translated to ` ` by + // DH instead of `+` which breaks the login flow. It's possible we could + // address this on DH server, but seems easier to just replace any `+` + // characters. + .replace(/[+]/g, 'x') + ); +} diff --git a/src/util/promiseUtils.spec.ts b/src/util/promiseUtils.spec.ts index 146d2e87..0c34c22c 100644 --- a/src/util/promiseUtils.spec.ts +++ b/src/util/promiseUtils.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, it, expect, vi, afterAll } from 'vitest'; -import { waitFor, withResolvers } from './promiseUtils'; +import { rejectAfterTimeout, waitFor, withResolvers } from './promiseUtils'; // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); @@ -16,6 +16,35 @@ afterAll(() => { const resolved = vi.fn().mockName('resolved'); const rejected = vi.fn().mockName('rejected'); +describe('rejectAfterTimeout', () => { + it('should return a Promise that rejects after a given timeout', async () => { + const promise = rejectAfterTimeout(100, 'Cancelled by timeout.'); + + promise.catch(rejected); + + await vi.advanceTimersByTimeAsync(99); + expect(rejected).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(rejected).toHaveBeenCalledWith('Cancelled by timeout.'); + }); + + it('should clear the timeout when the subscriptions are disposed', async () => { + const disposables: { dispose: () => void }[] = []; + const promise = rejectAfterTimeout( + 100, + 'Cancelled by timeout.', + disposables + ); + + promise.catch(rejected); + + disposables[0].dispose(); + await vi.advanceTimersByTimeAsync(100); + expect(rejected).not.toHaveBeenCalled(); + }); +}); + describe('waitFor', () => { it('should return a Promise that resolves after a given timeout', async () => { waitFor(100).then(resolved); diff --git a/src/util/promiseUtils.ts b/src/util/promiseUtils.ts index eac7dd88..b3b05bed 100644 --- a/src/util/promiseUtils.ts +++ b/src/util/promiseUtils.ts @@ -1,3 +1,5 @@ +import * as vscode from 'vscode'; + export interface PromiseWithResolvers { promise: Promise; resolve: (value: T | PromiseLike) => void; @@ -9,6 +11,32 @@ export interface PromiseWithCancel { cancel: () => void; } +/** + * Return a Promise that rejects after a given number of milliseconds. + * @param timeoutMs Timeout in milliseconds + * @param reason Rejection reason + * @param disposables Optional array of disposables. If provided, add a + * disposable to clear the timeout when the subscriptions are disposed. + * @returns A Promise that rejects after the given timeout + */ +export function rejectAfterTimeout( + timeoutMs: number, + reason: string, + disposables?: vscode.Disposable[] +): Promise { + let timeoutId: NodeJS.Timeout; + + disposables?.push({ + dispose: () => { + clearTimeout(timeoutId); + }, + }); + + return new Promise( + (_, reject) => (timeoutId = setTimeout(() => reject(reason), timeoutMs)) + ); +} + /** * Return a Promise that resolves after a given number of milliseconds. * @param waitMs diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index e89f02b7..7905ca41 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -3,7 +3,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import archiver from 'archiver'; import type { - KeyPairCredentials, OperateAsUsername, PasswordCredentials, Username, @@ -26,6 +25,9 @@ import type { Psk, DependencyName, DependencyVersion, + AuthFlow, + LoginPromptCredentials, + MultiAuthConfig, } from '../types'; import { getFilePathDateToken, sortByStringProp } from './dataUtils'; import { assertDefined } from './assertUtil'; @@ -127,8 +129,38 @@ export async function createConnectionQuickPick( } /** - * Run user login workflow that prompts user for credentials. Prompts are - * conditional based on the provided arguments. + * Prompt the user for which auth flow to use. If there is only 1 enabled, just + * return it. + * @param authConfig + * @returns The selected auth flow or null if cancelled. + */ +export async function promptForAuthFlow( + authConfig: MultiAuthConfig +): Promise { + const result = await vscode.window.showQuickPick( + [ + { + iconPath: new vscode.ThemeIcon(ICON_ID.saml), + label: authConfig.samlConfig.providerName, + value: { type: 'saml', config: authConfig.samlConfig }, + }, + { + label: 'Basic Login', + value: { type: 'password' }, + }, + ] as const, + { ignoreFocusOut: true, title: 'Login' } + ); + + if (result == null) { + return null; + } + + return result?.value; +} + +/** + * Prompt user for credentials. Prompts are based on the provided arguments. * @param title Title for the prompts * @param userLoginPreferences User login preferences to determine default values * for user / operate as prompts. @@ -137,28 +169,24 @@ export async function createConnectionQuickPick( * one of these private keys or username/password. * @param showOperatesAs Whether to show the operate as prompt. */ -export async function runUserLoginWorkflow(args: { +export async function promptForCredentials(args: { title: string; userLoginPreferences?: UserLoginPreferences; privateKeyUserNames?: undefined | []; showOperatesAs?: boolean; }): Promise; -export async function runUserLoginWorkflow(args: { +export async function promptForCredentials(args: { title: string; userLoginPreferences?: UserLoginPreferences; privateKeyUserNames?: Username[]; showOperatesAs?: boolean; -}): Promise< - PasswordCredentials | Omit | undefined ->; -export async function runUserLoginWorkflow(args: { +}): Promise; +export async function promptForCredentials(args: { title: string; userLoginPreferences?: UserLoginPreferences; privateKeyUserNames?: Username[]; showOperatesAs?: boolean; -}): Promise< - PasswordCredentials | Omit | undefined -> { +}): Promise { const { title, userLoginPreferences,