diff --git a/client/src/components/profile.ts b/client/src/components/profile.ts index 9ef9766d4..50438153b 100644 --- a/client/src/components/profile.ts +++ b/client/src/components/profile.ts @@ -88,6 +88,7 @@ export interface SSHProfile extends BaseProfile { saspath: string; port: number; username: string; + privateKeyFilePath?: string; } export interface COMProfile extends BaseProfile { @@ -580,6 +581,15 @@ export class ProfileConfig { return; } + const keyPath = await createInputTextBox( + ProfilePromptType.PrivateKeyFilePath, + profileClone.privateKeyFilePath, + ); + + if (keyPath) { + profileClone.privateKeyFilePath = keyPath; + } + await this.upsertProfile(name, profileClone); } else if (profileClone.connectionType === ConnectionType.COM) { profileClone.sasOptions = []; @@ -657,6 +667,7 @@ export enum ProfilePromptType { SASPath, Port, Username, + PrivateKeyFilePath, } /** @@ -794,6 +805,11 @@ const input: ProfilePromptInput = { placeholder: l10n.t("Enter your username"), description: l10n.t("Enter your SAS server username."), }, + [ProfilePromptType.PrivateKeyFilePath]: { + title: l10n.t("Private Key File Path (optional)"), + placeholder: l10n.t("Enter the local private key file path"), + description: l10n.t("To use the SSH Agent or a password, leave blank."), + }, }; /** diff --git a/client/src/connection/itc/LineParser.ts b/client/src/connection/LineParser.ts similarity index 100% rename from client/src/connection/itc/LineParser.ts rename to client/src/connection/LineParser.ts diff --git a/client/src/connection/itc/index.ts b/client/src/connection/itc/index.ts index 20d2e9416..61c58bbcb 100644 --- a/client/src/connection/itc/index.ts +++ b/client/src/connection/itc/index.ts @@ -18,9 +18,9 @@ import { getSecretStorage, } from "../../components/ExtensionContext"; import { updateStatusBarItem } from "../../components/StatusBarItem"; +import { LineParser } from "../LineParser"; import { Session } from "../session"; import { extractOutputHtmlFileName } from "../util"; -import { LineParser } from "./LineParser"; import { ERROR_END_TAG, ERROR_START_TAG, diff --git a/client/src/connection/ssh/auth.ts b/client/src/connection/ssh/auth.ts new file mode 100644 index 000000000..8be028adb --- /dev/null +++ b/client/src/connection/ssh/auth.ts @@ -0,0 +1,254 @@ +// Copyright © 2022-2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { l10n, window } from "vscode"; + +import { readFileSync } from "fs"; +import { NextAuthHandler, ParsedKey, Prompt, utils } from "ssh2"; + +/** + * Abstraction for presenting authentication prompts to the user. + */ +export interface AuthPresenter { + /** + * Prompt the user for a passphrase. + * @returns the passphrase entered by the user + */ + presentPasswordPrompt: (username: string) => Promise; + /** + * Prompt the user for a password. + * @returns the password entered by the user + */ + presentPassphrasePrompt: () => Promise; + /** + * Present multiple prompts to the user. + * This scenario can happen when the server sends multiple input prompts to the user during keyboard-interactive authentication. + * Auth setups involving MFA or PAM can trigger this scenario. + * One input box will be presented for each prompt. + * @param prompts an array of prompts to present to the user + * @returns array of answers to the prompts + */ + presentMultiplePrompts: ( + username: string, + prompts: Prompt[], + ) => Promise; +} + +class AuthPresenterImpl implements AuthPresenter { + presentPasswordPrompt = async (username: string): Promise => { + return this.presentPrompt( + l10n.t("Enter the password for user: {username}", { username }), + l10n.t("Password Required"), + true, + ); + }; + + presentPassphrasePrompt = async (): Promise => { + return this.presentPrompt( + l10n.t("Enter the passphrase for the private key"), + l10n.t("Passphrase Required"), + true, + ); + }; + + presentMultiplePrompts = async ( + username: string, + prompts: Prompt[], + ): Promise => { + const answers: string[] = []; + for (const prompt of prompts) { + const answer = await this.presentPrompt( + undefined, + l10n.t("User {username} {prompt}", { + username, + prompt: prompt.prompt, + }), + !prompt.echo, + ); + if (answer) { + answers.push(answer); + } + } + return answers; + }; + + /** + * Present a secure prompt to the user. + * @param prompt the prompt to display to the user + * @param title optional title for the prompt + * @param isSecureInput whether the input should be hidden + * @returns the user's response to the prompt + */ + private presentPrompt = async ( + prompt: string, + title?: string, + isSecureInput?: boolean, + ): Promise => { + return window.showInputBox({ + ignoreFocusOut: true, + prompt: prompt, + title: title, + password: isSecureInput, + }); + }; +} + +/** + * Handles the authentication process for the ssh connection. + * + */ +export class AuthHandler { + private _authPresenter: AuthPresenter; + private _keyParser: KeyParser; + + constructor(authPresenter?: AuthPresenter, keyParser?: KeyParser) { + this._authPresenter = authPresenter; + this._keyParser = keyParser; + + if (!authPresenter) { + this._authPresenter = new AuthPresenterImpl(); + } + if (!keyParser) { + this._keyParser = new KeyParserImpl(); + } + } + + /** + * Authenticate to the server using the password method. + * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. + * @param resolve a function that resolves the promise that is waiting for the password + * @param username the user name to use for the connection + */ + passwordAuth = (cb: NextAuthHandler, username: string) => { + this._authPresenter.presentPasswordPrompt(username).then((pw) => { + cb({ + type: "password", + password: pw, + username: username, + }); + }); + }; + + /** + * Authenticate to the server using the keyboard-interactive method. + * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. + * @param resolve a function that resolves the promise that is waiting for authentication + * @param username the user name to use for the connection + */ + keyboardInteractiveAuth = (cb: NextAuthHandler, username: string) => { + cb({ + type: "keyboard-interactive", + username: username, + prompt: (_name, _instructions, _instructionsLang, prompts, finish) => { + // often, the server will only send a single prompt for the password. + // however, PAM can send multiple prompts, so we need to handle that case + this._authPresenter + .presentMultiplePrompts(username, prompts) + .then((answers) => { + finish(answers); + }); + }, + }); + }; + + /** + * Authenticate to the server using the ssh-agent. See the extension Docs for more information on how to set up the ssh-agent. + * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. + * @param username the user name to use for the connection + */ + sshAgentAuth = (cb: NextAuthHandler, username: string) => { + cb({ + type: "agent", + agent: process.env.SSH_AUTH_SOCK, + username: username, + }); + }; + + /** + * Authenticate to the server using a private key file. + * If a private key file is defined in the connection profile, this function will read the file and use it to authenticate to the server. + * If the key is encrypted, the user will be prompted for the passphrase. + * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. + * @param resolve a function that resolves the promise that is waiting for authentication + * @param privateKeyFilePath the path to the private key file defined in the connection profile + * @param username the user name to use for the connection + */ + privateKeyAuth = ( + cb: NextAuthHandler, + privateKeyFilePath: string, + username: string, + ) => { + // first, try to parse the key file without a passphrase + const parsedKeyResult = this._keyParser.parseKey(privateKeyFilePath); + const hasParseError = parsedKeyResult instanceof Error; + const passphraseRequired = + hasParseError && + parsedKeyResult.message === + "Encrypted private OpenSSH key detected, but no passphrase given"; + // key is encrypted, prompt for passphrase + if (passphraseRequired) { + this._authPresenter.presentPassphrasePrompt().then((passphrase) => { + //parse the keyfile using the passphrase + const passphrasedKeyContentsResult = this._keyParser.parseKey( + privateKeyFilePath, + passphrase, + ); + + if (passphrasedKeyContentsResult instanceof Error) { + throw passphrasedKeyContentsResult; + } + cb({ + type: "publickey", + key: passphrasedKeyContentsResult, + passphrase: passphrase, + username: username, + }); + }); + } else { + if (hasParseError) { + throw parsedKeyResult; + } + cb({ + type: "publickey", + key: parsedKeyResult, + username: username, + }); + } + }; +} + +/** + * Parses a private key file. + */ +export interface KeyParser { + /** + * Parse the private key file. + * If a passphrase is specified, the key will be decrypted using the passphrase. + * @param privateKeyPath the path to the private key file + * @param passphrase the passphrase to decrypt the key if applicable + * @returns the parsed key or an error if the key could not be parsed + */ + parseKey: (privateKeyPath: string, passphrase?: string) => ParsedKey | Error; +} + +class KeyParserImpl implements KeyParser { + private readKeyFile = (privateKeyPath: string): Buffer => { + try { + return readFileSync(privateKeyPath); + } catch (e) { + throw new Error( + l10n.t("Error reading private key file: {filePath}, error: {message}", { + filePath: privateKeyPath, + message: e.message, + }), + ); + } + }; + + public parseKey = ( + privateKeyPath: string, + passphrase?: string, + ): ParsedKey | Error => { + const keyContents = this.readKeyFile(privateKeyPath); + return utils.parseKey(keyContents, passphrase); + }; +} diff --git a/client/src/connection/ssh/const.ts b/client/src/connection/ssh/const.ts new file mode 100644 index 000000000..791db9ddc --- /dev/null +++ b/client/src/connection/ssh/const.ts @@ -0,0 +1,11 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +export const KEEPALIVE_INTERVAL = 60 * SECOND; //How often (in milliseconds) to send SSH-level keepalive packets to the server. Set to 0 to disable. +export const KEEPALIVE_UNANSWERED_THRESHOLD = + (15 * MINUTE) / KEEPALIVE_INTERVAL; //How many consecutive, unanswered SSH-level keepalive packets that can be sent to the server before disconnection. +export const WORK_DIR_START_TAG = ""; +export const WORK_DIR_END_TAG = ""; +export const CONNECT_READY_TIMEOUT = 5 * MINUTE; //allow extra time due to possible prompting diff --git a/client/src/connection/ssh/index.ts b/client/src/connection/ssh/index.ts index 4a540ab9b..1f98973be 100644 --- a/client/src/connection/ssh/index.ts +++ b/client/src/connection/ssh/index.ts @@ -2,15 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 import { l10n } from "vscode"; -import { Client, ClientChannel, ConnectConfig } from "ssh2"; +import { + AuthHandlerMiddleware, + AuthenticationType, + Client, + ClientChannel, + ConnectConfig, + NextAuthHandler, +} from "ssh2"; import { BaseConfig, RunResult } from ".."; import { updateStatusBarItem } from "../../components/StatusBarItem"; +import { LineParser } from "../LineParser"; import { Session } from "../session"; import { extractOutputHtmlFileName } from "../util"; +import { AuthHandler } from "./auth"; +import { + CONNECT_READY_TIMEOUT, + KEEPALIVE_INTERVAL, + KEEPALIVE_UNANSWERED_THRESHOLD, + WORK_DIR_END_TAG, + WORK_DIR_START_TAG, +} from "./const"; +import { LineCodes } from "./types"; -const endCode = "--vscode-sas-extension-submit-end--"; -const sasLaunchTimeout = 10000; let sessionInstance: SSHSession; export interface Config extends BaseConfig { @@ -18,35 +33,38 @@ export interface Config extends BaseConfig { username: string; saspath: string; port: number; + privateKeyFilePath?: string; } export function getSession(c: Config): Session { - if (!process.env.SSH_AUTH_SOCK) { - throw new Error( - l10n.t("SSH_AUTH_SOCK not set. Check Environment Variables."), - ); - } - if (!sessionInstance) { - sessionInstance = new SSHSession(); + sessionInstance = new SSHSession(c, new Client()); } - sessionInstance.config = c; return sessionInstance; } - export class SSHSession extends Session { - private conn: Client; - private stream: ClientChannel | undefined; + private _conn: Client; + private _stream: ClientChannel | undefined; private _config: Config; - private resolve: ((value?) => void) | undefined; - private reject: ((reason?) => void) | undefined; - private html5FileName = ""; - private timer: NodeJS.Timeout; - - constructor(c?: Config) { + private _resolve: ((value?) => void) | undefined; + private _reject: ((reason?) => void) | undefined; + private _html5FileName = ""; + private _sessionReady: boolean; + private _authHandler: AuthHandler; + private _workDirectory: string; + private _workDirectoryParser: LineParser; + + constructor(c?: Config, client?: Client) { super(); this._config = c; - this.conn = new Client(); + this._conn = client; + this._sessionReady = false; + this._authHandler = new AuthHandler(); + this._workDirectoryParser = new LineParser( + WORK_DIR_START_TAG, + WORK_DIR_END_TAG, + false, + ); } public sessionId? = (): string => { @@ -59,11 +77,11 @@ export class SSHSession extends Session { protected establishConnection = (): Promise => { return new Promise((pResolve, pReject) => { - this.resolve = pResolve; - this.reject = pReject; + this._resolve = pResolve; + this._reject = pReject; - if (this.stream) { - this.resolve?.({}); + if (this._stream) { + this._resolve?.({}); return; } @@ -71,76 +89,74 @@ export class SSHSession extends Session { host: this._config.host, port: this._config.port, username: this._config.username, - readyTimeout: sasLaunchTimeout, - agent: process.env.SSH_AUTH_SOCK || undefined, + readyTimeout: CONNECT_READY_TIMEOUT, + keepaliveInterval: KEEPALIVE_INTERVAL, + keepaliveCountMax: KEEPALIVE_UNANSWERED_THRESHOLD, + authHandler: this.handleSSHAuthentication, }; - this.conn + if (!this._conn) { + this._conn = new Client(); + } + + this._conn + .on("close", this.onConnectionClose) .on("ready", () => { - this.conn.shell(this.onShell); + this._conn.shell(this.onShell); }) .on("error", this.onConnectionError); - this.setTimer(); - this.conn.connect(cfg); + this._conn.connect(cfg); }); }; public run = (code: string): Promise => { - this.html5FileName = ""; + this._html5FileName = ""; return new Promise((_resolve, _reject) => { - this.resolve = _resolve; - this.reject = _reject; + this._resolve = _resolve; + this._reject = _reject; - this.stream?.write(`${code}\n`); - this.stream?.write(`%put ${endCode};\n`); + this._stream?.write(`${code}\n`); + this._stream?.write(`%put ${LineCodes.RunEndCode};\n`); }); }; public close = (): void | Promise => { - if (!this.stream) { + if (!this._stream) { return; } - this.stream.write("endsas;\n"); - this.stream.close(); + this._stream.write("endsas;\n"); + this._stream.close(); }; - private onConnectionError = (err: Error) => { - this.clearTimer(); - this.reject?.(err); + private onConnectionClose = () => { + this._stream = undefined; + this._resolve = undefined; + this._reject = undefined; + this._html5FileName = ""; + this._workDirectory = undefined; + this.clearAuthState(); + sessionInstance = undefined; }; - private setTimer = (): void => { - this.clearTimer(); - this.timer = setTimeout(() => { - this.reject?.( - new Error( - l10n.t("Failed to connect to Session. Check profile settings."), - ), - ); - this.timer = undefined; - this.close(); - }, sasLaunchTimeout); - }; - - private clearTimer = (): void => { - this.timer && clearTimeout(this.timer); - this.timer = undefined; + private onConnectionError = (err: Error) => { + this.clearAuthState(); + this._reject?.(err); }; private getResult = (): void => { const runResult: RunResult = {}; - if (!this.html5FileName) { - this.resolve?.(runResult); + if (!this._html5FileName) { + this._resolve?.(runResult); return; } let fileContents = ""; - this.conn.exec( - `cat ${this.html5FileName}.htm`, + this._conn.exec( + `cat ${this._workDirectory}/${this._html5FileName}.htm`, (err: Error, s: ClientChannel) => { if (err) { - this.reject?.(err); + this._reject?.(err); return; } @@ -157,28 +173,55 @@ export class SSHSession extends Session { runResult.title = l10n.t("Result"); } } - this.resolve?.(runResult); + this._resolve?.(runResult); }); }, ); }; private onStreamClose = (): void => { - this.stream = undefined; - this.resolve = undefined; - this.reject = undefined; - this.html5FileName = ""; - this.timer = undefined; - this.conn.end(); + this._conn.end(); updateStatusBarItem(false); }; + private fetchWorkDirectory = (line: string): string => { + let foundWorkDirectory = ""; + if ( + !line.includes(`%put ${WORK_DIR_START_TAG};`) && + !line.includes(`%put &workDir;`) && + !line.includes(`%put ${WORK_DIR_END_TAG};`) + ) { + foundWorkDirectory = this._workDirectoryParser.processLine(line); + } else { + // If the line is the put statement, we don't need to log that + return; + } + // We don't want to output any of the captured lines + if (this._workDirectoryParser.isCapturingLine()) { + return; + } + + return foundWorkDirectory || ""; + }; + + private resolveSystemVars = (): void => { + const code = `%let workDir = %sysfunc(pathname(work)); + %put ${WORK_DIR_START_TAG}; + %put &workDir; + %put ${WORK_DIR_END_TAG}; + %let rc = %sysfunc(dlgcdir("&workDir")); + run; + `; + this._stream.write(code); + }; + private onStreamData = (data: Buffer): void => { const output = data.toString().trimEnd(); - if (this.timer && output.endsWith("?")) { - this.clearTimer(); - this.resolve?.(); + if (!this._sessionReady && output.endsWith("?")) { + this._sessionReady = true; + this._resolve?.(); + this.resolveSystemVars(); updateStatusBarItem(true); return; } @@ -188,33 +231,42 @@ export class SSHSession extends Session { if (!line) { return; } - if (line.endsWith(endCode)) { + const trimmedLine = line.trimEnd(); + if (trimmedLine.endsWith(LineCodes.RunEndCode)) { // run completed this.getResult(); } - if (!(line.trimEnd().endsWith("?") || line.endsWith(">"))) { - this.html5FileName = extractOutputHtmlFileName( + if (!(trimmedLine.endsWith("?") || trimmedLine.endsWith(">"))) { + this._html5FileName = extractOutputHtmlFileName( line, - this.html5FileName, + this._html5FileName, ); this._onExecutionLogFn?.([{ type: "normal", line }]); } + + if (this._sessionReady && !this._workDirectory) { + const foundWorkDir = this.fetchWorkDirectory(line); + if (foundWorkDir) { + const match = foundWorkDir.match(/\/[^\s\r]+/); + this._workDirectory = match ? match[0] : ""; + } + } }); }; private onShell = (err: Error, s: ClientChannel): void => { if (err) { - this.reject?.(err); + this._reject?.(err); return; } - this.stream = s; - if (!this.stream) { - this.reject?.(err); + this._stream = s; + if (!this._stream) { + this._reject?.(err); return; } - this.stream.on("close", this.onStreamClose); - this.stream.on("data", this.onStreamData); + this._stream.on("close", this.onStreamClose); + this._stream.on("data", this.onStreamData); const resolvedEnv: string[] = [ "env", @@ -233,6 +285,62 @@ export class SSHSession extends Session { } const execSasOpts: string = resolvedSasOpts.join(" "); - this.stream.write(`${execArgs} ${this._config.saspath} ${execSasOpts} \n`); + this._stream.write(`${execArgs} ${this._config.saspath} ${execSasOpts} \n`); + }; + + /** + * Resets the SSH auth state. + */ + private clearAuthState = (): void => { + this._sessionReady = false; + }; + + private handleSSHAuthentication: AuthHandlerMiddleware = ( + authsLeft: AuthenticationType[], + _partialSuccess: boolean, + nextAuth: NextAuthHandler, + ) => { + if (!authsLeft) { + nextAuth("none"); //sending none will prompt the server to send supported auth methods + return; + } + + if (authsLeft.length === 0) { + this._reject?.( + new Error(l10n.t("Could not authenticate to the SSH server.")), + ); + this.clearAuthState(); + return false; //returning false will stop the authentication process + } + + const authMethod = authsLeft.shift(); + switch (authMethod) { + case "publickey": { + //user set a keyfile path in profile config + if (this._config.privateKeyFilePath) { + this._authHandler.privateKeyAuth( + nextAuth, + this._config.privateKeyFilePath, + this._config.username, + ); + } else if (process.env.SSH_AUTH_SOCK) { + this._authHandler.sshAgentAuth(nextAuth, this._config.username); + } + break; + } + case "password": { + this._authHandler.passwordAuth(nextAuth, this._config.username); + break; + } + case "keyboard-interactive": { + this._authHandler.keyboardInteractiveAuth( + nextAuth, + this._config.username, + ); + break; + } + default: + nextAuth(authMethod); + } }; } diff --git a/client/src/connection/ssh/types.ts b/client/src/connection/ssh/types.ts new file mode 100644 index 000000000..092e0a064 --- /dev/null +++ b/client/src/connection/ssh/types.ts @@ -0,0 +1,18 @@ +// Copyright © 2022-2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BaseConfig } from ".."; + +export interface Config extends BaseConfig { + host: string; + username: string; + saspath: string; + port: number; + privateKeyFilePath: string; +} + +export enum LineCodes { + ResultsFetchedCode = "--vscode-sas-extension-results-fetched--", + RunCancelledCode = "--vscode-sas-extension-run-cancelled--", + RunEndCode = "--vscode-sas-extension-submit-end--", + LogLineType = "--vscode-sas-extension-log-line-type--", +} diff --git a/client/test/components/profile/profile.test.ts b/client/test/components/profile/profile.test.ts index 3ef72a670..60bf40622 100644 --- a/client/test/components/profile/profile.test.ts +++ b/client/test/components/profile/profile.test.ts @@ -92,6 +92,7 @@ describe("Profiles", async function () { sasPath: "sasPath", sasOptions: ["-nonews"], connectionType: "ssh", + privateKeyFilePath: "/private/key/file/path", }, }, }; @@ -654,6 +655,7 @@ describe("Profiles", async function () { sasOptions: ["-nonews"], saspath: "/sas/path", username: "username", + privateKeyFilePath: "/private/key/file/path", }; // Arrange // Act @@ -969,6 +971,13 @@ describe("Profiles", async function () { wantDescription: "Enter your SAS server username.", wantPlaceHolder: "Enter your username", }, + { + name: "Private Key File Path", + prompt: ProfilePromptType.PrivateKeyFilePath, + wantTitle: "Private Key File Path (optional)", + wantDescription: "To use the SSH Agent or a password, leave blank.", + wantPlaceHolder: "Enter the local private key file path", + }, ]; testCases.forEach((testCase) => { diff --git a/client/test/connection/ssh/auth.test.ts b/client/test/connection/ssh/auth.test.ts new file mode 100644 index 000000000..f29637c83 --- /dev/null +++ b/client/test/connection/ssh/auth.test.ts @@ -0,0 +1,152 @@ +import { expect } from "chai"; +import * as fs from "fs"; +import { KeyboardInteractiveAuthMethod, ParsedKey } from "ssh2"; +import sinon, { stubInterface } from "ts-sinon"; + +import { + AuthHandler, + AuthPresenter, + KeyParser, +} from "../../../src/connection/ssh/auth"; + +describe("ssh connection auth handler", () => { + let authHandler: AuthHandler; + + describe("sshAgentAuth", () => { + beforeEach(() => { + process.env.SSH_AUTH_SOCK = "socketPath"; + }); + + it("pass socket path to the callback", async () => { + const cb = sinon.stub(); + const username = "username"; + const socketPath = "socketPath"; + + const presenter = stubInterface(); + authHandler = new AuthHandler(presenter); + + await authHandler.sshAgentAuth(cb, username); + + sinon.assert.calledWith(cb, { + type: "agent", + agent: socketPath, + username: username, + }); + }); + }); + + describe("privateKeyAuth", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should pass the key contents and passphrase to the callback for an encrypted key", async () => { + const cb = sinon.stub(); + const username = "username"; + const passphrase = "passphrase"; + const privateKeyFilePath = "privateKeyFilePath"; + + const presenter = stubInterface(); + const keyParser = stubInterface(); + + const key = stubInterface(); + keyParser.parseKey + .withArgs(privateKeyFilePath) + .returns( + new Error( + "Encrypted private OpenSSH key detected, but no passphrase given", + ), + ); + keyParser.parseKey.withArgs(privateKeyFilePath, passphrase).returns(key); + presenter.presentPassphrasePrompt.resolves(passphrase); + + authHandler = new AuthHandler(presenter, keyParser); + + await authHandler.privateKeyAuth(cb, privateKeyFilePath, username); + + sinon.assert.calledWith(cb, { + type: "publickey", + key: key, + passphrase: passphrase, + username: username, + }); + }); + + it("should pass the key contents to the callback for an unencrypted key", async () => { + const cb = sandbox.stub(); + const username = "username"; + const privateKeyFilePath = "privateKeyFilePath"; + + sandbox + .stub(fs, "readFileSync") + .callsFake(() => Buffer.from("keyContents")); + + const presenter = stubInterface(); + const keyParser = stubInterface(); + + const key = stubInterface(); + keyParser.parseKey.returns(key); + authHandler = new AuthHandler(presenter, keyParser); + + await authHandler.privateKeyAuth(cb, privateKeyFilePath, username); + + sinon.assert.calledWith(cb, { + type: "publickey", + key: key, + username: username, + }); + }); + }); + + describe("passwordAuth", () => { + it("should pass the password to the callback", async () => { + const cb = sinon.stub(); + const username = "username"; + const pw = "password"; + + const presenter = stubInterface(); + presenter.presentPasswordPrompt.resolves(pw); + + authHandler = new AuthHandler(presenter); + + await authHandler.passwordAuth(cb, username); + + sinon.assert.calledWith(cb, { + type: "password", + password: pw, + username: username, + }); + }); + }); + + describe("keyboardAuth", () => { + it("should present input prompts and pass the answers to the callback", async () => { + const promptCbStub = sinon.stub(); + const cb = (auth: KeyboardInteractiveAuthMethod) => { + expect(auth.type).to.equal("keyboard-interactive"); + expect(auth.username).to.equal("username"); + auth.prompt( + "name", + "instruction", + "lang", + [{ prompt: "question1" }, { prompt: "question2" }], + promptCbStub, + ); + }; + + const answers = ["answer1", "answer2"]; + const presenter = stubInterface(); + presenter.presentMultiplePrompts.resolves(answers); + + authHandler = new AuthHandler(presenter); + + await authHandler.keyboardInteractiveAuth(cb, "username"); + sinon.assert.calledWith(promptCbStub, answers); + }); + }); +}); diff --git a/client/test/connection/ssh/index.test.ts b/client/test/connection/ssh/index.test.ts index ddc217052..7f55ea4f2 100644 --- a/client/test/connection/ssh/index.test.ts +++ b/client/test/connection/ssh/index.test.ts @@ -124,28 +124,6 @@ describe("ssh connection", () => { await session.setup(); }, "Shell Connection Failed"); }); - - it("rejects on ready timeout", async () => { - const config = { - host: "host", - username: "username", - port: 22, - saspath: "/path/to/sas_u8", - sasOptions: [], - agentSocket: "/agent/socket", - }; - - sandbox.stub(Client.prototype, "connect").callsFake(function () { - sandbox.clock.tick(50 * seconds); - this.emit("ready"); - return undefined; - }); - - session = new SSHSession(config); - await assertThrowsAsync(async () => { - await session.setup(); - }, "Failed to connect to Session. Check profile settings"); - }); }); describe("run", () => { diff --git a/package.json b/package.json index 7b1ee3147..54fcd60bd 100644 --- a/package.json +++ b/package.json @@ -321,6 +321,11 @@ "description": "%configuration.SAS.connectionProfiles.profiles.ssh.port%", "exclusiveMinimum": 1, "exclusiveMaximum": 65535 + }, + "privateKeyFilePath": { + "type": "string", + "default": "", + "description": "%configuration.SAS.connectionProfiles.profiles.ssh.privateKeyFilePath%" } } } diff --git a/package.nls.json b/package.nls.json index f6adaed12..ae6fd6022 100644 --- a/package.nls.json +++ b/package.nls.json @@ -51,6 +51,7 @@ "configuration.SAS.connectionProfiles.profiles.ssh.port": "SAS SSH Connection port", "configuration.SAS.connectionProfiles.profiles.ssh.saspath": "SAS SSH Connection executable path", "configuration.SAS.connectionProfiles.profiles.ssh.username": "SAS SSH Connection username", + "configuration.SAS.connectionProfiles.profiles.ssh.privateKeyFilePath": "SAS SSH Connection private key file path", "configuration.SAS.flowConversionMode": "Choose the conversion mode for notebooks", "configuration.SAS.flowConversionModeNode": "Convert each notebook cell to a node", "configuration.SAS.flowConversionModeSwimlane": "Convert each notebook cell to a swimlane", diff --git a/website/docs/Configurations/Profiles/sas9ssh.md b/website/docs/Configurations/Profiles/sas9ssh.md index 0e646f47b..522dfb6f8 100644 --- a/website/docs/Configurations/Profiles/sas9ssh.md +++ b/website/docs/Configurations/Profiles/sas9ssh.md @@ -4,7 +4,7 @@ sidebar_position: 4 # SAS 9.4 (remote - SSH) Connection Profile -For a secure connection to SAS 9.4 (remote - SSH) server, a public / private SSH key pair is required. The socket defined in the environment variable `SSH_AUTH_SOCK` is used to communicate with ssh-agent to authenticate the SSH session. The private key must be registered with the ssh-agent. The steps for configuring SSH follow. +This connection method uses SSH to authenticate to a SAS Server and run SAS Code using [Interactive Line Mode](https://go.documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/n16ui9f6dacn8pn1t0y2hgxgi7wa.htm). A number of methods are available to create a secure connection to the SAS 9.4 server. :::note @@ -18,18 +18,29 @@ A SAS 9.4 (remote – SSH) connection profile includes the following parameters: `"connectionType": "ssh"` -| Name | Description | Additional Notes | -| ---------- | ------------------------------------ | -------------------------------------------------------------------------- | -| `host` | SSH Server Host | Appears when hovering over the status bar. | -| `username` | SSH Server Username | The username to establish the SSH connection to the server. | -| `port` | SSH Server Port | The SSH port of the SSH server. The default value is 22. | -| `saspath` | Path to SAS Executable on the server | Must be a fully qualified path on the SSH server to a SAS executable file. | +| Name | Description | Additional Notes | +| -------------------- | ------------------------------------ | ----------------------------------------------------------------------------- | +| `host` | SSH Server Host | Appears when hovering over the status bar. | +| `username` | SSH Server Username | The username to establish the SSH connection to the server. | +| `port` | SSH Server Port | The SSH port of the SSH server. The default value is 22. | +| `saspath` | Path to SAS Executable on the server | Must be a fully qualified path on the SSH server to a SAS executable file. | +| `privateKeyFilePath` | SSH Private Key File (optional) | Must be a fully qualified path on the same machine that VSCode is running on. | -## Required setup for connection to SAS 9.4 (remote - SSH) +## Authenticating to a SAS Server -In order to configure the connection between VS Code and SAS 9, you must configure OpenSSH. Follow the steps below to complete the setup. +The extension will attempt to authenticate to the SAS Server over ssh using the auth methods specified in the SSH Server configuration defined on the SAS Server. The extension currently supports using the SSH auth methods listed below: -### Windows +- [Publickey](#publickey) +- [Password](#password) +- [Keyboard Interactive](#keyboard-interactive) + +### Publickey + +#### SSH Agent + +When using publickey SSH authentication, The extension can be configured to use keys defined in the SSH Agent. The socket defined in the environment variable `SSH_AUTH_SOCK` is used to communicate with ssh-agent to authenticate the SSH session. The private key must be registered with the ssh-agent when using this method. The steps for configuring SSH follow. Follow the steps below to complete the setup. + +##### Windows 1. Enable OpenSSH for Windows using these [instructions](https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse?tabs=gui). @@ -80,7 +91,7 @@ Note: the default path to the SAS executable (saspath) is /opt/sasinside/SASHome 9. Add the public part of the keypair to the SAS server. Add the contents of the key file to the ~/.ssh/authorized_keys file. -### Mac +##### Mac 1. Start ssh-agent in the background: @@ -118,3 +129,26 @@ Host host.machine.name ``` 6. Add the public part of the keypair to the SAS server. Add the contents of the key file to the ~/.ssh/authorized_keys file. + +#### Private Key File Path + +A private key can optionally be specified in the `privateKeyFilePath` field in the connection profile for SAS 9.4 (remote - SSH). This is useful for auth setups where the SSH Agent cannot be used. If a private key file contains a passphrase, the user will be prompted to enter it during each Session creation for which it is used. + +```json +"ssh_test": { + "connectionType": "ssh", + "host": "host.machine.name", + "saspath": "/path/to/sas/executable", + "username": "username", + "port": 22, + "privateKeyFilePath": "/path/to/privatekey/file" +} +``` + +### Password + +Enter the password using the secure input prompt during each Session creation. To authenticate without using a password, configure the extension using one of the Publickey setups. + +### Keyboard Interactive + +Enter the response to each question using the secure input prompts during each Session creation.