Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

If connect with stored password fails, delete and re-prompt #238

Merged
merged 4 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions src/api/getServerSpec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import * as vscode from "vscode";
import { IServerSpec } from "@intersystems-community/intersystems-servermanager";

interface ICredentialSet {
username: string;
password: string;
}

export let credentialCache = new Map<string, ICredentialSet>();

/**
* Get a server specification.
*
* @param name The name.
* @param scope The settings scope to use for the lookup.
* @param flushCredentialCache Flush the session's cache of credentials obtained from keystore and/or user prompting.
* @param noCredentials Set username and password as undefined; do not fetch credentials from anywhere.
* @returns Server specification or undefined.
*/
export async function getServerSpec(
name: string,
scope?: vscode.ConfigurationScope,
flushCredentialCache: boolean = false,
noCredentials: boolean = false,
): Promise<IServerSpec | undefined> {
if (flushCredentialCache) {
credentialCache[name] = undefined;
}
// To avoid breaking existing users, continue to return a default server definition even after we dropped that feature
let server: IServerSpec | undefined = vscode.workspace.getConfiguration("intersystems.servers", scope).get(name) || legacyEmbeddedServer(name);

Expand Down
78 changes: 61 additions & 17 deletions src/authenticationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "vscode";
import { ServerManagerAuthenticationSession } from "./authenticationSession";
import { globalState } from "./extension";
import { getServerSpec } from "./api/getServerSpec";
import { makeRESTRequest } from "./makeRESTRequest";

export const AUTHENTICATION_PROVIDER = "intersystems-server-credentials";
const AUTHENTICATION_PROVIDER_LABEL = "InterSystems Server Credentials";
Expand All @@ -35,6 +37,7 @@ export class ServerManagerAuthenticationProvider implements AuthenticationProvid
private readonly _secretStorage;

private _sessions: ServerManagerAuthenticationSession[] = [];
private _checkedSessions: ServerManagerAuthenticationSession[] = [];

private _serverManagerExtension = extensions.getExtension("intersystems-community.servermanager");

Expand All @@ -52,7 +55,7 @@ export class ServerManagerAuthenticationProvider implements AuthenticationProvid
this._initializedDisposable?.dispose();
}

// This function is called first when `vscode.authentication.getSessions` is called.
// This function is called first when `vscode.authentication.getSession` is called.
public async getSessions(scopes: string[] = []): Promise<readonly AuthenticationSession[]> {
await this._ensureInitialized();
let sessions = this._sessions;
Expand All @@ -61,7 +64,13 @@ export class ServerManagerAuthenticationProvider implements AuthenticationProvid
for (let index = 0; index < scopes.length; index++) {
sessions = sessions.filter((session) => session.scopes[index] === scopes[index].toLowerCase());
}
return sessions;

if (sessions.length === 1) {
if (!(await this._isStillValid(sessions[0]))) {
sessions = [];
}
}
return sessions || [];
}

// This function is called after `this.getSessions` is called, and only when:
Expand Down Expand Up @@ -104,9 +113,17 @@ export class ServerManagerAuthenticationProvider implements AuthenticationProvid

// Return existing session if found
const sessionId = ServerManagerAuthenticationProvider.sessionId(serverName, userName);
const existingSession = this._sessions.find((s) => s.id === sessionId);
let existingSession = this._sessions.find((s) => s.id === sessionId);
if (existingSession) {
return existingSession;
if (this._checkedSessions.find((s) => s.id === sessionId)) {
return existingSession;
}

// Check if the session is still valid
if (await this._isStillValid(existingSession)) {
this._checkedSessions.push(existingSession);
return existingSession;
}
}

let password: string | undefined = "";
Expand Down Expand Up @@ -190,25 +207,52 @@ export class ServerManagerAuthenticationProvider implements AuthenticationProvid
return session;
}

private async _isStillValid(session: ServerManagerAuthenticationSession): Promise<boolean> {
if (this._checkedSessions.find((s) => s.id === session.id)) {
return true;
}
const serverSpec = await getServerSpec(session.serverName);
if (serverSpec) {
serverSpec.username = session.userName;
serverSpec.password = session.accessToken;
const response = await makeRESTRequest("HEAD", serverSpec);
if (response?.status === 401) {
await this._removeSession(session.id, true);
return false;
}
}
this._checkedSessions.push(session);
return true;
}

// This function is called when the end user signs out of the account.
public async removeSession(sessionId: string): Promise<void> {
this._removeSession(sessionId);
}

private async _removeSession(sessionId: string, alwaysDeletePassword = false): Promise<void> {
const index = this._sessions.findIndex((item) => item.id === sessionId);
const session = this._sessions[index];

let deletePassword = false;
const credentialKey = ServerManagerAuthenticationProvider.credentialKey(sessionId);
if (await this.secretStorage.get(credentialKey)) {
const passwordOption = workspace.getConfiguration("intersystemsServerManager.credentialsProvider")
.get<string>("deletePasswordOnSignout", "ask");
deletePassword = (passwordOption === "always");
if (passwordOption === "ask") {
const choice = await window.showWarningMessage(
`Do you want to keep the password or delete it?`,
{ detail: `The ${AUTHENTICATION_PROVIDER_LABEL} account you signed out (${session.account.label}) is currently storing its password securely on your workstation.`, modal: true },
{ title: "Keep", isCloseAffordance: true },
{ title: "Delete", isCloseAffordance: false },
);
deletePassword = (choice?.title === "Delete");
let deletePassword = false;
const hasStoredPassword = await this.secretStorage.get(credentialKey) !== undefined;
if (alwaysDeletePassword) {
deletePassword = hasStoredPassword;
} else {
if (hasStoredPassword) {
const passwordOption = workspace.getConfiguration("intersystemsServerManager.credentialsProvider")
.get<string>("deletePasswordOnSignout", "ask");
deletePassword = (passwordOption === "always");
if (passwordOption === "ask") {
const choice = await window.showWarningMessage(
`Do you want to keep the password or delete it?`,
{ detail: `The ${AUTHENTICATION_PROVIDER_LABEL} account you signed out (${session.account.label}) is currently storing its password securely on your workstation.`, modal: true },
{ title: "Keep", isCloseAffordance: true },
{ title: "Delete", isCloseAffordance: false },
);
deletePassword = (choice?.title === "Delete");
}
}
}
if (deletePassword) {
Expand Down
18 changes: 9 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export function activate(context: vscode.ExtensionContext) {
if (pathParts && pathParts.length === 4) {
const serverName = pathParts[1];
const namespace = pathParts[3];
const serverSpec = await getServerSpec(serverName, undefined, undefined, true);
const serverSpec = await getServerSpec(serverName);
if (serverSpec) {
const ISFS_ID = "intersystems-community.vscode-objectscript";
const isfsExtension = vscode.extensions.getExtension(ISFS_ID);
Expand Down Expand Up @@ -379,28 +379,28 @@ export function activate(context: vscode.ExtensionContext) {
/**
* Get specification for the named server.
*
* If the `"intersystemsServerManager.authentication.provider"` setting is "intersystems-server-credentials":
* - the returned object will not contain `password`. To get this:
* The returned object will not contain `password`. To get that:
* ```
* const session = await vscode.authentication.getSession('intersystems-server-credentials', [serverSpec.name, serverSpec.username]);
* const session: vscode.AuthenticationSession = await vscode.authentication.getSession('intersystems-server-credentials', [serverSpec.name, serverSpec.username]);
* ```
* The `accessToken` property of the returned [`AuthenticationSession`](https://code.visualstudio.com/api/references/vscode-api#AuthenticationSession) is the password.
* - `flushCredentialsCache` param will be ignored;
* - `noCredentials` property of `options` param has no effect;
*
* The `flushCredentialsCache` param is obsolete and has no effect;
* The `noCredentials` property of `options` param is obsolete and has no effect;
*
* @param name Name of the server, used as the key into the 'intersystems.servers' settings object
* @param scope Settings scope to look in.
* @param flushCredentialCache If passed as true, flush extension's credential cache.
* @param flushCredentialCache Obsolete, has no effect.
* @param options
* @returns { IServerSpec } Server specification object.
*/
async getServerSpec(
name: string,
scope?: vscode.ConfigurationScope,
flushCredentialCache: boolean = false,
options?: { hideFromRecents?: boolean, noCredentials?: boolean },
options?: { hideFromRecents?: boolean, /* Obsolete */ noCredentials?: boolean },
): Promise<IServerSpec | undefined> {
const spec = await getServerSpec(name, scope, flushCredentialCache, options?.noCredentials);
const spec = await getServerSpec(name, scope);
if (spec && !options?.hideFromRecents) {
await view.addToRecents(name);
}
Expand Down
4 changes: 2 additions & 2 deletions src/makeRESTRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export async function makeRESTRequest(
return respdata;
} catch (error) {
console.log(error);
return undefined;
return error.response;
}
}

Expand All @@ -176,7 +176,7 @@ export async function makeRESTRequest(
*/
export async function logout(serverName: string) {

const server = await getServerSpec(serverName, undefined, false, true);
const server = await getServerSpec(serverName, undefined);

if (!server) {
return;
Expand Down
21 changes: 12 additions & 9 deletions src/ui/serverManagerView.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { getServerNames } from "../api/getServerNames";
import { credentialCache, getServerSpec } from "../api/getServerSpec";
import { getServerSpec } from "../api/getServerSpec";
import { getServerSummary } from "../api/getServerSummary";
import { IServerName } from "@intersystems-community/intersystems-servermanager";
import { makeRESTRequest } from "../makeRESTRequest";
Expand Down Expand Up @@ -383,10 +383,14 @@ async function serverFeatures(element: ServerTreeItem, params?: any): Promise<Fe
return undefined;
}

const response = await makeRESTRequest("HEAD", serverSpec);
if (!response || response.status !== 200) {
let response = await makeRESTRequest("HEAD", serverSpec);
if (response?.status === 401) {
// Authentication error, so retry in case first attempt cleared a no-longer-valid stored password
serverSpec.password = undefined;
response = await makeRESTRequest("HEAD", serverSpec);
}
if (response?.status !== 200) {
children.push(new OfflineTreeItem({ parent: element, label: name, id: name }, serverSpec.username || 'UnknownUser'));
credentialCache[name] = undefined;
} else {
children.push(new NamespacesTreeItem({ parent: element, label: name, id: name }, element.name, serverSpec.username || 'UnknownUser'));
}
Expand Down Expand Up @@ -459,12 +463,11 @@ async function serverNamespaces(element: ServerTreeItem, params?: any): Promise<
}

const response = await makeRESTRequest("GET", serverSpec);
if (!response || response.status !== 200) {
if (response?.status !== 200) {
children.push(new OfflineTreeItem({ parent: element, label: name, id: name }, serverSpec.username || 'UnknownUser'));
credentialCache[params.serverName] = undefined;
} else {
const serverApiVersion = response.data.result.content.api;
response.data.result.content.namespaces.map((namespace) => {
response.data.result.content.namespaces.map((namespace: string) => {
children.push(new NamespaceTreeItem({ parent: element, label: name, id: name }, namespace, name, serverApiVersion));
});
}
Expand Down Expand Up @@ -557,7 +560,7 @@ async function namespaceProjects(element: ProjectsTreeItem, params?: any): Promi
{ apiVersion: 1, namespace: params.ns, path: "/action/query" },
{ query: "SELECT Name, Description FROM %Studio.Project", parameters: [] }
);
if (response !== undefined) {
if (response?.status === 200) {
if (response.data.result.content === undefined) {
let message;
if (response.data.status?.errors[0]?.code === 5540) {
Expand Down Expand Up @@ -643,7 +646,7 @@ async function namespaceWebApps(element: ProjectsTreeItem, params?: any): Promis
serverSpec,
{ apiVersion: 1, namespace: "%SYS", path: `/cspapps/${params.ns}` }
);
if (response !== undefined) {
if (response?.status === 200) {
if (response.data.result.content === undefined) {
vscode.window.showErrorMessage(response.data.status.summary);
return undefined;
Expand Down
Loading