diff --git a/src/api/index.ts b/src/api/index.ts index 4e1d5dd2..981b02a1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,6 +19,7 @@ const DEFAULT_API_VERSION = 1; const DEFAULT_SERVER_VERSION = "2016.2.0"; import * as Atelier from "./atelier"; import { isfsConfig } from "../utils/FileProviderUtil"; +import { Authorization, IServerSpec } from "@intersystems-community/intersystems-servermanager"; // Map of the authRequest promises for each username@host:port/pathPrefix target to avoid concurrency issues const authRequestMap = new Map>(); @@ -26,7 +27,7 @@ const authRequestMap = new Map>(); /** Map of `username@host:port/pathPrefix` to cookies */ const cookiesMap = new Map(); -interface ConnectionSettings { +export interface ConnectionSettings { serverName: string; active: boolean; apiVersion: number; @@ -37,8 +38,7 @@ interface ConnectionSettings { superserverPort?: number; pathPrefix: string; ns: string; - username: string; - password: string; + auth: Authorization; docker: boolean; dockerService?: string; } @@ -59,7 +59,7 @@ export class AtelierAPI { } public get config(): ConnectionSettings { - const { serverName, active = false, https = false, pathPrefix = "", username } = this._config; + const { serverName, active = false, https = false, pathPrefix = "", auth } = this._config; const ns = this.namespace || this._config.ns; const wsKey = this.configName.toLowerCase(); const host = this.externalServer ? this._config.host : workspaceState.get(wsKey + ":host", this._config.host); @@ -67,7 +67,7 @@ export class AtelierAPI { const superserverPort = this.externalServer ? this._config.superserverPort : workspaceState.get(wsKey + ":superserverPort", this._config.superserverPort); - const password = workspaceState.get(wsKey + ":password", this._config.password); + auth.resolve({ accessToken: workspaceState.get(wsKey + ":password", undefined) }); const apiVersion = workspaceState.get(wsKey + ":apiVersion", DEFAULT_API_VERSION); const serverVersion = workspaceState.get(wsKey + ":serverVersion", DEFAULT_SERVER_VERSION); const docker = workspaceState.get(wsKey + ":docker", false); @@ -83,8 +83,7 @@ export class AtelierAPI { superserverPort, pathPrefix, ns, - username, - password, + auth, docker, dockerService, }; @@ -147,14 +146,12 @@ export class AtelierAPI { * Manually set the connection spec for this object, * where `connSpec` is the return value of `getResolvedConnectionSpec()`. */ - public setConnSpec(serverName: string, connSpec: any): void { + public setConnSpec(serverName: string, connSpec: IServerSpec): void { const { webServer: { scheme, host, port, pathPrefix = "" }, - username, - password, + auth, } = connSpec; - this._config.username = username; - this._config.password = password; + this._config.auth = auth; this._config.https = scheme == "https"; this._config.host = host; this._config.port = port; @@ -209,12 +206,12 @@ export class AtelierAPI { /** Return the key for getting values from connection-specific Maps for this connection */ private mapKey(): string { - const { host, port, username } = this.config; + const { host, port, auth } = this.config; let pathPrefix = this._config.pathPrefix || ""; if (pathPrefix.length && !pathPrefix.startsWith("/")) { pathPrefix = "/" + pathPrefix; } - return `${username}@${host}:${port}${pathPrefix}`; + return `${auth.username}@${host}:${port}${pathPrefix}`; } private setConnection(workspaceFolderName: string, namespace?: string): void { @@ -239,8 +236,7 @@ export class AtelierAPI { if (serverName !== "") { const { webServer: { scheme, host, port, pathPrefix = "" }, - username, - password, + auth, superServer, } = getResolvedConnectionSpec(serverName, config("intersystems.servers", workspaceFolderName).get(serverName)); this._config = { @@ -253,8 +249,7 @@ export class AtelierAPI { host, port, superserverPort: superServer?.port, - username, - password, + auth, pathPrefix, docker: false, }; @@ -264,8 +259,7 @@ export class AtelierAPI { if (resolvedSpec) { const { webServer: { scheme, host, port, pathPrefix = "" }, - username, - password, + auth, superServer, } = resolvedSpec; this._config = { @@ -278,8 +272,7 @@ export class AtelierAPI { host, port, superserverPort: superServer?.port, - username, - password, + auth, pathPrefix, docker: true, dockerService: conn["docker-compose"].service, @@ -316,7 +309,7 @@ export class AtelierAPI { headers?: any, options?: any ): Promise { - const { active, apiVersion, host, port, username, password, https } = this.config; + const { active, apiVersion, host, port, https } = this.config; if (!active || !port || !host) { return Promise.reject(); } @@ -369,12 +362,11 @@ export class AtelierAPI { let auth: Promise; let authRequest = authRequestMap.get(mapKey); if (cookies.length || (method === "HEAD" && !originalPath)) { - auth = Promise.resolve(cookies); - // Only send basic authorization if username and password specified (including blank, for unauthenticated access) - if (typeof username === "string" && typeof password === "string") { - headers["Authorization"] = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + if (this.config.auth.resolved()) { + headers["Authorization"] = this.config.auth.httpAuthorizationHeader; } + auth = Promise.resolve(cookies); } else if (!cookies.length) { if (!authRequest) { // Recursion point @@ -449,7 +441,7 @@ export class AtelierAPI { if (this.wsOrFile && !checkingConnection) { setTimeout(() => { checkConnection( - password ? true : false, + this.config.auth.resolved(), typeof this.wsOrFile === "object" ? this.wsOrFile : undefined, true ); diff --git a/src/commands/connectFolderToServerNamespace.ts b/src/commands/connectFolderToServerNamespace.ts index 1cd26e16..b4076888 100644 --- a/src/commands/connectFolderToServerNamespace.ts +++ b/src/commands/connectFolderToServerNamespace.ts @@ -7,7 +7,7 @@ import { serverManagerApi, resolveUsernameAndPassword, } from "../extension"; -import { handleError, isUnauthenticated, notIsfs, displayableUri } from "../utils"; +import { handleError, notIsfs, displayableUri } from "../utils"; interface ConnSettings { server: string; @@ -80,7 +80,7 @@ export async function connectFolderToServerNamespace(): Promise { .serverInfo(false) .then((data) => data.result.content.namespaces) .catch(async (error) => { - if (error?.statusCode == 401 && isUnauthenticated(api.config.username)) { + if (error?.statusCode == 401 && !api.config.auth.resolved()) { // Attempt to resolve username and password and try again const newSpec = await resolveUsernameAndPassword(api.config.serverName, connSpec); if (newSpec) { diff --git a/src/commands/restDebugPanel.ts b/src/commands/restDebugPanel.ts index ae3344eb..29030211 100644 --- a/src/commands/restDebugPanel.ts +++ b/src/commands/restDebugPanel.ts @@ -501,8 +501,8 @@ export class RESTDebugPanel { form.onchange = () => sendData(false); button.onclick = () => sendData(true); // Bubble change events up to the form - bodyContent.onchange = headersText.onchange = - paramsText.onchange = path.onchange = + bodyContent.onchange = headersText.onchange = + paramsText.onchange = path.onchange = () => form.dispatchEvent(new Event("change")); @@ -548,16 +548,7 @@ export class RESTDebugPanel { .trim(); } }); - if ( - headers["authorization"] == undefined && - typeof api.config.username === "string" && - typeof api.config.password === "string" - ) { - // Use the server connection's auth if the user didn't specify any - headers["authorization"] = `Basic ${Buffer.from(`${api.config.username}:${api.config.password}`).toString( - "base64" - )}`; - } + headers["authorization"] = headers["authorization"] ?? (api.config.auth.httpAuthorizationHeader || ""); const hasBody = typeof message.bodyContent == "string" && message.bodyContent != "" && message.bodyType != "No Body"; if (hasBody) { diff --git a/src/commands/serverActions.ts b/src/commands/serverActions.ts index a9a14fdb..c157909c 100644 --- a/src/commands/serverActions.ts +++ b/src/commands/serverActions.ts @@ -25,7 +25,7 @@ type ServerAction = { detail: string; id: string; label: string; rawLink?: strin export async function serverActions(): Promise { const { apiTarget, configName: workspaceFolder } = connectionTarget(); const api = new AtelierAPI(apiTarget); - const { active, host = "", ns = "", https, port = 0, pathPrefix, username, docker } = api.config; + const { active, host = "", ns = "", https, port = 0, pathPrefix, auth, docker } = api.config; const explorerCount = (await explorerProvider.getChildren()).length; if (!explorerCount && (!docker || host === "")) { await vscode.commands.executeCommand("ObjectScriptExplorer.focus"); @@ -152,7 +152,7 @@ export async function serverActions(): Promise { .replace("${serverAuth}", "") .replace("${ns}", nsEncoded) .replace("${namespace}", ns == "%SYS" ? "sys" : nsEncoded.toLowerCase()) - .replace("${username}", username) + .replace("${username}", auth.username) .replace("${classname}", classname) .replace("${classnameEncoded}", classnameEncoded) .replace("${project}", project); @@ -248,7 +248,7 @@ export async function serverActions(): Promise { if (addin) { sendStudioAddinTelemetryEvent(addin.label); let params = `Namespace=${nsEncoded}`; - params += `&User=${encodeURIComponent(username)}`; + params += `&User=${encodeURIComponent(auth.username)}`; if (project != "") { params += `&Project=${encodeURIComponent(project)}`; } diff --git a/src/commands/webSocketTerminal.ts b/src/commands/webSocketTerminal.ts index 9b71df36..b77b9d2e 100644 --- a/src/commands/webSocketTerminal.ts +++ b/src/commands/webSocketTerminal.ts @@ -225,7 +225,7 @@ class WebSocketTerminal implements vscode.Pseudoterminal { this._hideCursorWrite("\x1b]633;P;HasRichCommandDetection=True\x07"); // Print the opening message this._hideCursorWrite( - `\x1b[32mConnected to \x1b[0m\x1b[4m${api.config.host}:${api.config.port}${api.config.pathPrefix}\x1b[0m\x1b[32m as \x1b[0m\x1b[3m${api.config.username}\x1b[0m\r\n` + `\x1b[32mConnected to \x1b[0m\x1b[4m${api.config.host}:${api.config.port}${api.config.pathPrefix}\x1b[0m\x1b[32m as \x1b[0m\x1b[3m${api.config.auth.username}\x1b[0m\r\n` ); // Add event handlers to the socket this._socket diff --git a/src/extension.ts b/src/extension.ts index a0a741e0..2e61ebc2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -85,7 +85,6 @@ import { portFromDockerCompose, notNull, currentFile, - isUnauthenticated, notIsfs, handleError, cspApps, @@ -208,8 +207,12 @@ export let checkingConnection = false; export let serverManagerApi: serverManager.ServerManagerAPI; +interface ResolvedConnSpec extends serverManager.IServerSpec { + auth: serverManager.ResolvedAuthorization; +} + /** Map of the intersystems.server connection specs we have resolved via the API to that extension */ -const resolvedConnSpecs = new Map(); +const resolvedConnSpecs = new Map(); /** * If servermanager extension is available, fetch the connection spec unless already cached. @@ -255,28 +258,27 @@ export async function resolveConnectionSpec( superServer: { port: serverForUri.superserverPort, }, - username: serverForUri.username, - password: serverForUri.password ? serverForUri.password : undefined, description: `Server for workspace folder '${serverName}'`, + auth: serverManagerApi.defaultAuth(), }; } } } if (connSpec) { - await resolvePassword(connSpec); - resolvedConnSpecs.set(serverName, connSpec); + const accessToken = await resolvePassword(connSpec); + if (connSpec.auth.resolve({ accessToken })) { + resolvedConnSpecs.set(serverName, connSpec); + } } } -async function resolvePassword(serverSpec, ignoreUnauthenticated = false): Promise { - if ( - // Connection isn't unauthenticated - (!isUnauthenticated(serverSpec.username) || ignoreUnauthenticated) && - // A password is missing - typeof serverSpec.password == "undefined" - ) { - const scopes = [serverSpec.name, serverSpec.username || ""]; +async function resolvePassword( + serverSpec: serverManager.IServerSpec, + ignoreUnauthenticated = false +): Promise { + if (!(serverSpec.auth.resolved() as boolean) || ignoreUnauthenticated) { + const scopes = [serverSpec.name, serverSpec.auth?.username || ""]; // Handle Server Manager extension version < 3.8.0 const account = serverManagerApi.getAccount ? serverManagerApi.getAccount(serverSpec) : undefined; @@ -292,33 +294,33 @@ async function resolvePassword(serverSpec, ignoreUnauthenticated = false): Promi }); } if (session) { - // If original spec lacked username use the one obtained from the user by the authprovider (exact case) - serverSpec.username = serverSpec.username || session.scopes[1]; - serverSpec.password = session.accessToken; + return session.accessToken; } } } /** Resolve credentials for `serverName` and returned the complete connection spec if successful */ -export async function resolveUsernameAndPassword(serverName: string, oldSpec: any): Promise { - const newSpec: { name: string; username?: string; password?: string } = { - name: serverName, - username: oldSpec?.username, - }; - await resolvePassword(newSpec, true); - if (newSpec.password) { +export async function resolveUsernameAndPassword( + serverName: string, + oldSpec: serverManager.IServerSpec +): Promise { + const { auth: _auth, ...newSpec } = oldSpec; + newSpec.name = serverName; + const auth = _auth?.clone(); + + const accessToken = await resolvePassword({ ...newSpec, auth }, true); + if (auth?.resolve({ accessToken })) { // Update the connection spec resolvedConnSpecs.set(serverName, { ...oldSpec, - username: newSpec.username, - password: newSpec.password, + auth, }); return resolvedConnSpecs.get(serverName); } } /** Accessor for the cache of resolved connection specs */ -export function getResolvedConnectionSpec(key: string, dflt: any): any { +export function getResolvedConnectionSpec(key: string, dflt: ResolvedConnSpec): ResolvedConnSpec { let spec = resolvedConnSpecs.get(key); if (spec) { return spec; @@ -364,7 +366,7 @@ export async function checkConnection( _onDidChangeConnection.fire(); } let api = new AtelierAPI(apiTarget, false); - const { active, host = "", port = 0, superserverPort = 0, username, ns = "" } = api.config; + const { active, host = "", port = 0, superserverPort = 0, ns = "", auth } = api.config; vscode.commands.executeCommand("setContext", "vscode-objectscript.connectActive", active); if (!panel.text) { panel.text = `${PANEL_LABEL}`; @@ -449,9 +451,11 @@ export async function checkConnection( panel.text = api.connInfo; const { serverName, host, port, pathPrefix } = api.config; if (serverName) { - panel.tooltip = new vscode.MarkdownString(`Connected to \`${host}:${port}${pathPrefix}\` as \`${username}\``); + panel.tooltip = new vscode.MarkdownString( + `Connected to \`${host}:${port}${pathPrefix}\` as \`${auth.username}\`` + ); } else { - panel.tooltip = new vscode.MarkdownString(`Connected as \`${username}\``); + panel.tooltip = new vscode.MarkdownString(`Connected as \`${auth.username}\``); } inactiveServerIds.delete(api.serverId); if (!api.externalServer) await setConnectionState(configName, true); @@ -470,8 +474,7 @@ export async function checkConnection( let success = false; message = "Not Authorized."; errorMessage = `Authorization error: Check your credentials in Settings, and that you have sufficient privileges on the /api/atelier web application on ${connInfo}`; - const username = api.config.username; - if (isUnauthenticated(username)) { + if (!api.config.auth.resolved()) { vscode.window.showErrorMessage( `Unauthenticated access rejected by '${api.serverId}'.${ !api.config.serverName ? " Connection has been disabled." : "" @@ -487,7 +490,7 @@ export async function checkConnection( const newSpec = await resolveUsernameAndPassword(api.config.serverName, oldSpec); if (newSpec) { // We were able to resolve credentials, so try again - await workspaceState.update(wsKey + ":password", newSpec.password); + await workspaceState.update(wsKey + ":password", newSpec.auth?.accessToken); api = new AtelierAPI(apiTarget, false); await api .serverInfo(true, serverInfoTimeout) @@ -515,7 +518,7 @@ export async function checkConnection( vscode.window .showInputBox({ password: true, - title: `Not Authorized. Enter password to connect as user '${username}' to ${connInfo}`, + title: `Not Authorized. Enter password to connect as user '${api.config.auth.username}' to ${connInfo}`, prompt: !api.externalServer ? "If no password is entered the connection will be disabled." : "", ignoreFocusOut: true, }) @@ -850,7 +853,7 @@ let macLangConf: vscode.Disposable; let incLangConf: vscode.Disposable; let intLangConf: vscode.Disposable; -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate(context: vscode.ExtensionContext): Promise { if (!packageJson.version.includes("-") || packageJson.version.includes("-beta.")) { // Don't send telemetry for development builds try { @@ -1947,7 +1950,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { // This function is exported as one of our API functions but is also used internally // for example to implement the async variant capable of resolving docker port number. -function serverForUri(uri: vscode.Uri): any { +function serverForUri(uri: vscode.Uri): serverManager.ServerForUri { const { apiTarget, configName } = connectionTarget(uri); const configNameLower = configName.toLowerCase(); const api = new AtelierAPI(apiTarget); @@ -1955,44 +1958,34 @@ function serverForUri(uri: vscode.Uri): any { // This function intentionally no longer exposes the password for a named server UNLESS it is already exposed as plaintext in settings. // API client extensions should use Server Manager 3's authentication provider to request a missing password themselves, // which will require explicit user consent to divulge the password to the requesting extension. - const { - serverName, - active, - host = "", - https, - port, - superserverPort, - pathPrefix, - username, - password, - ns = "", - apiVersion, - serverVersion, - } = api.config; + const { serverName, active, host, https, port, superserverPort, pathPrefix, auth, ns, apiVersion, serverVersion } = + api.config; + if (serverName !== "") { + const password = vscode.workspace + .getConfiguration( + `intersystems.servers.${serverName.toLowerCase()}`, + // objectscript(xml):// URIs are not in any workspace folder, + // so make sure we resolve the server definition with the proper + // granularity. This is needed to prevent other extensions like + // Language Server prompting for a passwoord when it's not needed. + [OBJECTSCRIPT_FILE_SCHEMA, OBJECTSCRIPTXML_FILE_SCHEMA].includes(uri.scheme) + ? vscode.workspace.workspaceFolders?.find((f) => f.name.toLowerCase() == configNameLower)?.uri + : uri + ) + .get("password") as string | undefined; + if (password !== undefined) { + auth.resolve({ accessToken: password }); + } + } return { serverName, active, - scheme: https ? "https" : "http", + ...(https ? ({ https: true, scheme: "https" } as const) : ({ scheme: "http" } as const)), host, port, superserverPort, pathPrefix, - username, - password: - serverName === "" - ? password - : vscode.workspace - .getConfiguration( - `intersystems.servers.${serverName.toLowerCase()}`, - // objectscript(xml):// URIs are not in any workspace folder, - // so make sure we resolve the server definition with the proper - // granularity. This is needed to prevent other extensions like - // Language Server prompting for a passwoord when it's not needed. - [OBJECTSCRIPT_FILE_SCHEMA, OBJECTSCRIPTXML_FILE_SCHEMA].includes(uri.scheme) - ? vscode.workspace.workspaceFolders?.find((f) => f.name.toLowerCase() == configNameLower)?.uri - : uri - ) - .get("password"), + auth, namespace: ns, apiVersion: active ? apiVersion : undefined, serverVersion: active ? serverVersion : undefined, @@ -2001,7 +1994,7 @@ function serverForUri(uri: vscode.Uri): any { // An async variant capable of resolving docker port number. // It is exported as one of our API functions but is also used internally. -async function asyncServerForUri(uri: vscode.Uri): Promise { +async function asyncServerForUri(uri: vscode.Uri): Promise { const server = serverForUri(uri); if (!server.port) { let { apiTarget } = connectionTarget(uri); diff --git a/src/providers/FileSystemProvider/FileSystemProvider.ts b/src/providers/FileSystemProvider/FileSystemProvider.ts index 709d03f4..5c7f4afe 100644 --- a/src/providers/FileSystemProvider/FileSystemProvider.ts +++ b/src/providers/FileSystemProvider/FileSystemProvider.ts @@ -476,10 +476,10 @@ export class FileSystemProvider implements vscode.FileSystemProvider { .catch((error) => { if (error) { if (error.errorText.includes(" #5540:")) { - const message = `User '${api.config.username}' cannot list ${ + const message = `User '${api.config.auth.username}' cannot list ${ csp ? `web application '${uri.path}'` : "namespace" } contents. If they do not have READ permission on the default code database of the ${api.config.ns.toUpperCase()} namespace then grant it and retry. If the problem remains then execute the following SQL in that namespace:\n\t GRANT EXECUTE ON %Library.RoutineMgr_StudioOpenDialog TO ${ - api.config.username + api.config.auth.username }`; handleError(message); } @@ -1006,7 +1006,7 @@ export class FileSystemProvider implements vscode.FileSystemProvider { private async _lookup(uri: vscode.Uri, fillInPath?: boolean): Promise { const api = new AtelierAPI(uri); const config = api.config; - const rootName = `${config.username}@${config.host}:${config.port}${config.pathPrefix}/${config.ns.toUpperCase()}`; + const rootName = `${config.auth.username}@${config.host}:${config.port}${config.pathPrefix}/${config.ns.toUpperCase()}`; let entry: Entry = this.superRoot.entries.get(rootName); if (!entry) { entry = new Directory(rootName, ""); diff --git a/src/providers/LowCodeEditorProvider.ts b/src/providers/LowCodeEditorProvider.ts index 46009619..06e6ae51 100644 --- a/src/providers/LowCodeEditorProvider.ts +++ b/src/providers/LowCodeEditorProvider.ts @@ -212,8 +212,8 @@ export class LowCodeEditorProvider implements vscode.CustomTextEditorProvider { webviewPanel.webview.postMessage({ direction: "editor", type: "auth", - username: api.config.username, - password: api.config.password, + username: api.config.auth.username, + password: api.config.auth.accessToken, }); } return; diff --git a/src/utils/index.ts b/src/utils/index.ts index ba7ee6cc..9b022747 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -871,11 +871,6 @@ export function methodOffsetToLine( return line; } -/** Return `true` if this username signals unauthenticated access */ -export function isUnauthenticated(username: string): boolean { - return username == undefined || username == "" || username.toLowerCase() == "unknownuser"; -} - /** Returns `true` if `uri.scheme` is neither `isfs` nor `isfs-readonly` */ export function notIsfs(uri: vscode.Uri): boolean { return !filesystemSchemas.includes(uri.scheme); @@ -928,7 +923,7 @@ export async function getWsServerConnection(minVersion?: string): Promise