Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 104 additions & 22 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import * as cliExec from "./core/cliExec";
import { CertificateError } from "./error/certificateError";
import { toError } from "./error/errorUtils";
import { type FeatureSet, featureSetForVersion } from "./featureSet";
import {
AuthTelemetry,
type AuthLoginMethod,
type AuthLoginOutcome,
type AuthLoginSource,
type AuthLogoutOutcome,
} from "./instrumentation/auth";
import {
reportElapsedProgress,
withCancellableProgress,
Expand All @@ -37,6 +44,7 @@ import {
} from "./workspace/workspacesProvider";

import type {
User,
Workspace,
WorkspaceAgent,
} from "coder/site/src/api/typesGenerated";
Expand All @@ -48,6 +56,8 @@ import type { MementoManager } from "./core/mementoManager";
import type { PathResolver } from "./core/pathResolver";
import type { SecretsManager } from "./core/secretsManager";
import type { DeploymentManager } from "./deployment/deploymentManager";
import type { Deployment } from "./deployment/types";
import type { CredentialFailureCategory } from "./instrumentation/credentials";
import type { Logger } from "./logging/logger";
import type { LoginCoordinator } from "./login/loginCoordinator";
import type { TelemetryService } from "./telemetry/service";
Expand All @@ -68,6 +78,18 @@ interface OpenOptions {
useDefaultDirectory?: boolean;
}

interface LoginArgs {
readonly url?: string;
readonly autoLogin?: boolean;
}

type LoginMethodRecorder = (method: AuthLoginMethod) => void;

interface LoginSuccess {
readonly user: User;
readonly token: string;
}

const openDefaults = {
openRecent: false,
useDefaultDirectory: true,
Expand All @@ -83,6 +105,7 @@ export class Commands {
private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc;
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
private readonly telemetryService: TelemetryService;
private readonly authTelemetry: AuthTelemetry;

// These will only be populated when actively connected to a workspace and are
// used in commands. Because commands can be executed by the user, it is not
Expand All @@ -109,6 +132,7 @@ export class Commands {
this.loginCoordinator = serviceContainer.getLoginCoordinator();
this.duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc();
this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory();
this.authTelemetry = new AuthTelemetry(this.telemetryService);
}

/**
Expand All @@ -126,20 +150,35 @@ export class Commands {
* Log into a deployment. If already authenticated, this is a no-op.
* If no URL is provided, shows a menu of recent URLs plus defaults.
*/
public async login(args?: {
url?: string;
autoLogin?: boolean;
}): Promise<void> {
public async login(args?: LoginArgs): Promise<void> {
if (this.deploymentManager.isAuthenticated()) {
return;
}
await this.performLogin(args);
await this.traceLoginCommand(
args?.autoLogin ? "auto_login" : "command",
(recordMethod) => this.performLogin(args, recordMethod),
);
}

private async performLogin(args?: {
url?: string;
autoLogin?: boolean;
}): Promise<void> {
private async traceLoginCommand(
source: AuthLoginSource,
run: (recordMethod: LoginMethodRecorder) => Promise<AuthLoginOutcome>,
): Promise<void> {
let method: AuthLoginMethod = "unknown";
await this.authTelemetry.traceLogin(
source,
() => method,
() =>
run((next) => {
method = next;
}),
);
}

private async performLogin(
args: LoginArgs | undefined,
recordMethod: LoginMethodRecorder,
): Promise<AuthLoginOutcome> {
this.logger.debug("Logging in");

const currentDeployment = await this.secretsManager.getCurrentDeployment();
Expand All @@ -149,7 +188,7 @@ export class Commands {
currentDeployment?.url,
);
if (!url) {
return; // The user aborted.
return { success: false, reason: "no_url_provided" };
}

const safeHostname = toSafeHost(url);
Expand All @@ -159,22 +198,38 @@ export class Commands {
safeHostname,
url,
autoLogin: args?.autoLogin,
traceLogin: false,
onLoginMethod: recordMethod,
});

if (!result.success) {
return;
return result;
}

await this.completeLogin(url, safeHostname, result);
return { success: true };
}

private async completeLogin(
url: string,
safeHostname: string,
result: LoginSuccess,
): Promise<void> {
await this.deploymentManager.setDeployment({
url,
safeHostname,
token: result.token,
user: result.user,
});

this.showWelcomeMessage(result.user.username);
this.logger.debug("Login complete to deployment:", url);
}

private showWelcomeMessage(username: string): void {
vscode.window
.showInformationMessage(
`Welcome to Coder, ${result.user.username}!`,
`Welcome to Coder, ${username}!`,
{
detail:
"You can now use the Coder extension to manage your Coder instance.",
Expand All @@ -186,7 +241,6 @@ export class Commands {
vscode.commands.executeCommand("coder.open");
}
});
this.logger.debug("Login complete to deployment:", url);
}

/**
Expand Down Expand Up @@ -407,21 +461,37 @@ export class Commands {
* Log out and clear stored credentials, requiring re-authentication on next login.
*/
public async logout(): Promise<void> {
await this.authTelemetry.traceLogout(() => this.performLogout());
}

private async performLogout(): Promise<AuthLogoutOutcome> {
if (!this.deploymentManager.isAuthenticated()) {
return;
return { success: false, reason: "not_authenticated" };
}

this.logger.debug("Logging out");

const deployment = this.deploymentManager.getCurrentDeployment();
await this.deploymentManager.clearDeployment("logout");

await this.deploymentManager.clearDeployment();
const credentialFailureCategory = deployment
? await this.clearDeploymentCredentials(deployment)
: undefined;

if (deployment) {
await this.cliManager.clearCredentials(deployment.url);
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
}
this.showLogoutMessage();
this.logger.debug("Logout complete");
return logoutResultForCredentialFailure(credentialFailureCategory);
}

private async clearDeploymentCredentials(
deployment: Deployment,
): Promise<CredentialFailureCategory | undefined> {
const result = await this.cliManager.clearCredentials(deployment.url);
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
return result.failureCategory;
}

private showLogoutMessage(): void {
vscode.window
.showInformationMessage("You've been logged out of Coder!", "Login")
.then((action) => {
Expand All @@ -431,8 +501,6 @@ export class Commands {
});
}
});

this.logger.debug("Logout complete");
}

/**
Expand All @@ -441,7 +509,9 @@ export class Commands {
*/
public async switchDeployment(): Promise<void> {
this.logger.debug("Switching deployment");
await this.performLogin();
await this.traceLoginCommand("switch_deployment", (recordMethod) =>
this.performLogin(undefined, recordMethod),
);
}

/**
Expand Down Expand Up @@ -1210,6 +1280,18 @@ export class Commands {
}
}

function logoutResultForCredentialFailure(
failureCategory: CredentialFailureCategory | undefined,
): AuthLogoutOutcome {
if (failureCategory === "aborted") {
return { success: false, reason: "credential_clear_cancelled" };
}
if (failureCategory) {
return { success: false, reason: "credential_clear_failed" };
}
return { success: true };
}

async function openFile(filePath: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
await vscode.window.showTextDocument(uri);
Expand Down
Loading