Skip to content

Commit 125dd85

Browse files
committed
Handle per deployment login and centralized login logic
1 parent 82acb21 commit 125dd85

File tree

10 files changed

+1091
-597
lines changed

10 files changed

+1091
-597
lines changed

src/commands.ts

Lines changed: 36 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import { type Api } from "coder/site/src/api/api";
2-
import { getErrorMessage } from "coder/site/src/api/errors";
32
import {
4-
type User,
53
type Workspace,
64
type WorkspaceAgent,
75
} from "coder/site/src/api/typesGenerated";
86
import * as vscode from "vscode";
97

108
import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
11-
import { CoderApi } from "./api/coderApi";
12-
import { needToken } from "./api/utils";
139
import { type CliManager } from "./core/cliManager";
1410
import { type ServiceContainer } from "./core/container";
1511
import { type ContextManager } from "./core/contextManager";
@@ -19,8 +15,9 @@ import { type SecretsManager } from "./core/secretsManager";
1915
import { CertificateError } from "./error";
2016
import { getGlobalFlags } from "./globalFlags";
2117
import { type Logger } from "./logging/logger";
18+
import { type LoginCoordinator } from "./login/loginCoordinator";
2219
import { type OAuthSessionManager } from "./oauth/sessionManager";
23-
import { maybeAskAgent, maybeAskUrl, maybeAskAuthMethod } from "./promptUtils";
20+
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
2421
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2522
import {
2623
AgentTreeItem,
@@ -36,6 +33,8 @@ export class Commands {
3633
private readonly secretsManager: SecretsManager;
3734
private readonly cliManager: CliManager;
3835
private readonly contextManager: ContextManager;
36+
private readonly loginCoordinator: LoginCoordinator;
37+
3938
// These will only be populated when actively connected to a workspace and are
4039
// used in commands. Because commands can be executed by the user, it is not
4140
// possible to pass in arguments, so we have to store the current workspace
@@ -59,6 +58,7 @@ export class Commands {
5958
this.secretsManager = serviceContainer.getSecretsManager();
6059
this.cliManager = serviceContainer.getCliManager();
6160
this.contextManager = serviceContainer.getContextManager();
61+
this.loginCoordinator = serviceContainer.getLoginCoordinator();
6262
}
6363

6464
/**
@@ -79,42 +79,49 @@ export class Commands {
7979

8080
const url = await maybeAskUrl(this.mementoManager, args?.url);
8181
if (!url) {
82-
return; // The user aborted.
82+
return;
8383
}
8484

8585
// It is possible that we are trying to log into an old-style host, in which
8686
// case we want to write with the provided blank label instead of generating
8787
// a host label.
8888
const label = args?.label ?? toSafeHost(url);
89-
// Try to get a token from the user, if we need one, and their user.
90-
const autoLogin = args?.autoLogin === true;
89+
this.logger.info("Using deployment label", label);
90+
91+
const result = await this.loginCoordinator.promptForLogin({
92+
url,
93+
label,
94+
autoLogin: args?.autoLogin,
95+
oauthSessionManager: this.oauthSessionManager,
96+
});
9197

92-
const res = await this.attemptLogin(url, args?.token, autoLogin);
93-
if (!res) {
94-
return; // The user aborted, or unable to auth.
98+
if (!result.success || !result.user || !result.token) {
99+
return;
95100
}
96101

97-
// The URL is good and the token is either good or not required; authorize
98-
// the global client.
102+
// Authorize the global client
99103
this.restClient.setHost(url);
100-
this.restClient.setSessionToken(res.token);
104+
this.restClient.setSessionToken(result.token);
101105

102-
// Store these to be used in later sessions.
106+
// Store for later sessions
103107
await this.mementoManager.setUrl(url);
104-
await this.secretsManager.setSessionToken(res.token);
108+
await this.secretsManager.setSessionToken(label, {
109+
url,
110+
sessionToken: result.token,
111+
});
105112

106-
// Store on disk to be used by the cli.
107-
await this.cliManager.configure(label, url, res.token);
113+
// Store on disk for CLI
114+
await this.cliManager.configure(label, url, result.token);
108115

109-
// These contexts control various menu items and the sidebar.
116+
// Update contexts
110117
this.contextManager.set("coder.authenticated", true);
111-
if (res.user.roles.some((role) => role.name === "owner")) {
118+
if (result.user.roles.some((role) => role.name === "owner")) {
112119
this.contextManager.set("coder.isOwner", true);
113120
}
114121

115122
vscode.window
116123
.showInformationMessage(
117-
`Welcome to Coder, ${res.user.username}!`,
124+
`Welcome to Coder, ${result.user.username}!`,
118125
{
119126
detail:
120127
"You can now use the Coder extension to manage your Coder instance.",
@@ -127,160 +134,10 @@ export class Commands {
127134
}
128135
});
129136

130-
await this.secretsManager.triggerLoginStateChange("login");
131-
// Fetch workspaces for the new deployment.
137+
await this.secretsManager.triggerLoginStateChange(label, "login");
132138
vscode.commands.executeCommand("coder.refreshWorkspaces");
133139
}
134140

135-
/**
136-
* Attempt to authenticate using OAuth, token, or mTLS. If necessary, prompts
137-
* for authentication method and credentials. Returns the token and user upon
138-
* successful authentication. Null means the user aborted or authentication
139-
* failed (in which case an error notification will have been displayed).
140-
*/
141-
private async attemptLogin(
142-
url: string,
143-
token: string | undefined,
144-
isAutoLogin: boolean,
145-
): Promise<{ user: User; token: string } | null> {
146-
const client = CoderApi.create(url, token, this.logger);
147-
const needsToken = needToken(vscode.workspace.getConfiguration());
148-
if (!needsToken || token) {
149-
try {
150-
const user = await client.getAuthenticatedUser();
151-
// For non-token auth, we write a blank token since the `vscodessh`
152-
// command currently always requires a token file.
153-
// For token auth, we have valid access so we can just return the user here
154-
return { token: needsToken && token ? token : "", user };
155-
} catch (err) {
156-
const message = getErrorMessage(err, "no response from the server");
157-
if (isAutoLogin) {
158-
this.logger.warn("Failed to log in to Coder server:", message);
159-
} else {
160-
this.vscodeProposed.window.showErrorMessage(
161-
"Failed to log in to Coder server",
162-
{
163-
detail: message,
164-
modal: true,
165-
useCustom: true,
166-
},
167-
);
168-
}
169-
// Invalid certificate, most likely.
170-
return null;
171-
}
172-
}
173-
174-
const authMethod = await maybeAskAuthMethod(client);
175-
switch (authMethod) {
176-
case "oauth":
177-
return this.loginWithOAuth(client);
178-
case "legacy": {
179-
const initialToken =
180-
token || (await this.secretsManager.getSessionToken());
181-
return this.loginWithToken(client, initialToken);
182-
}
183-
case undefined:
184-
return null; // User aborted
185-
}
186-
}
187-
188-
private async loginWithToken(
189-
client: CoderApi,
190-
initialToken: string | undefined,
191-
): Promise<{ user: User; token: string } | null> {
192-
const url = client.getAxiosInstance().defaults.baseURL;
193-
if (!url) {
194-
throw new Error("No base URL set on REST client");
195-
}
196-
// This prompt is for convenience; do not error if they close it since
197-
// they may already have a token or already have the page opened.
198-
await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`));
199-
200-
// For token auth, start with the existing token in the prompt or the last
201-
// used token. Once submitted, if there is a failure we will keep asking
202-
// the user for a new token until they quit.
203-
let user: User | undefined;
204-
const validatedToken = await vscode.window.showInputBox({
205-
title: "Coder API Key",
206-
password: true,
207-
placeHolder: "Paste your API key.",
208-
value: initialToken,
209-
ignoreFocusOut: true,
210-
validateInput: async (value) => {
211-
if (!value) {
212-
return null;
213-
}
214-
client.setSessionToken(value);
215-
try {
216-
user = await client.getAuthenticatedUser();
217-
} catch (err) {
218-
// For certificate errors show both a notification and add to the
219-
// text under the input box, since users sometimes miss the
220-
// notification.
221-
if (err instanceof CertificateError) {
222-
err.showNotification();
223-
224-
return {
225-
message: err.x509Err || err.message,
226-
severity: vscode.InputBoxValidationSeverity.Error,
227-
};
228-
}
229-
// This could be something like the header command erroring or an
230-
// invalid session token.
231-
const message = getErrorMessage(err, "no response from the server");
232-
return {
233-
message: "Failed to authenticate: " + message,
234-
severity: vscode.InputBoxValidationSeverity.Error,
235-
};
236-
}
237-
},
238-
});
239-
240-
if (user === undefined || validatedToken === undefined) {
241-
return null;
242-
}
243-
244-
return { user, token: validatedToken };
245-
}
246-
247-
/**
248-
* Authenticate using OAuth flow.
249-
* Returns the access token and authenticated user, or null if failed/cancelled.
250-
*/
251-
private async loginWithOAuth(
252-
client: CoderApi,
253-
): Promise<{ user: User; token: string } | null> {
254-
try {
255-
this.logger.info("Starting OAuth authentication");
256-
257-
const tokenResponse = await vscode.window.withProgress(
258-
{
259-
location: vscode.ProgressLocation.Notification,
260-
title: "Authenticating",
261-
cancellable: false,
262-
},
263-
async (progress) =>
264-
await this.oauthSessionManager.login(client, progress),
265-
);
266-
267-
// Validate token by fetching user
268-
client.setSessionToken(tokenResponse.access_token);
269-
const user = await client.getAuthenticatedUser();
270-
271-
return {
272-
token: tokenResponse.access_token,
273-
user,
274-
};
275-
} catch (error) {
276-
this.logger.error("OAuth authentication failed:", error);
277-
vscode.window.showErrorMessage(
278-
`OAuth authentication failed: ${getErrorMessage(error, "Unknown error")}`,
279-
);
280-
return null;
281-
}
282-
}
283-
284141
/**
285142
* View the logs for the currently connected workspace.
286143
*/
@@ -316,15 +173,16 @@ export class Commands {
316173
throw new Error("You are not logged in");
317174
}
318175

319-
await this.forceLogout();
176+
await this.forceLogout(toSafeHost(url));
320177
}
321178

322-
public async forceLogout(): Promise<void> {
179+
public async forceLogout(label: string): Promise<void> {
323180
if (!this.contextManager.get("coder.authenticated")) {
324181
return;
325182
}
326-
this.logger.info("Logging out");
183+
this.logger.info(`Logging out of deployment: ${label}`);
327184

185+
// Only clear REST client and UI context if logging out of current deployment
328186
// Fire and forget
329187
this.oauthSessionManager.logout().catch((error) => {
330188
this.logger.warn("OAuth logout failed, continuing with cleanup:", error);
@@ -337,7 +195,7 @@ export class Commands {
337195

338196
// Clear from memory.
339197
await this.mementoManager.setUrl(undefined);
340-
await this.secretsManager.setSessionToken(undefined);
198+
await this.secretsManager.setSessionToken(label, undefined);
341199

342200
this.contextManager.set("coder.authenticated", false);
343201
vscode.window
@@ -348,9 +206,10 @@ export class Commands {
348206
}
349207
});
350208

351-
await this.secretsManager.triggerLoginStateChange("logout");
352209
// This will result in clearing the workspace list.
353210
vscode.commands.executeCommand("coder.refreshWorkspaces");
211+
212+
await this.secretsManager.triggerLoginStateChange(label, "logout");
354213
}
355214

356215
/**

src/core/container.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode";
22

33
import { type Logger } from "../logging/logger";
4+
import { LoginCoordinator } from "../login/loginCoordinator";
45

56
import { CliManager } from "./cliManager";
67
import { ContextManager } from "./contextManager";
@@ -19,6 +20,7 @@ export class ServiceContainer implements vscode.Disposable {
1920
private readonly secretsManager: SecretsManager;
2021
private readonly cliManager: CliManager;
2122
private readonly contextManager: ContextManager;
23+
private readonly loginCoordinator: LoginCoordinator;
2224

2325
constructor(
2426
context: vscode.ExtensionContext,
@@ -37,6 +39,11 @@ export class ServiceContainer implements vscode.Disposable {
3739
this.pathResolver,
3840
);
3941
this.contextManager = new ContextManager();
42+
this.loginCoordinator = new LoginCoordinator(
43+
this.secretsManager,
44+
this.vscodeProposed,
45+
this.logger,
46+
);
4047
}
4148

4249
getVsCodeProposed(): typeof vscode {
@@ -67,6 +74,10 @@ export class ServiceContainer implements vscode.Disposable {
6774
return this.contextManager;
6875
}
6976

77+
getLoginCoordinator(): LoginCoordinator {
78+
return this.loginCoordinator;
79+
}
80+
7081
/**
7182
* Dispose of all services and clean up resources.
7283
*/

0 commit comments

Comments
 (0)