Skip to content

Commit 0e8453b

Browse files
committed
Hook up into the login/logout logic of the extension
1 parent 0aad64e commit 0e8453b

File tree

5 files changed

+252
-109
lines changed

5 files changed

+252
-109
lines changed

package.json

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,6 @@
255255
"title": "Search",
256256
"category": "Coder",
257257
"icon": "$(search)"
258-
},
259-
{
260-
"command": "coder.oauth.login",
261-
"title": "OAuth Login",
262-
"category": "Coder"
263-
},
264-
{
265-
"command": "coder.oauth.logout",
266-
"title": "OAuth Logout",
267-
"category": "Coder"
268258
}
269259
],
270260
"menus": {

src/commands.ts

Lines changed: 180 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager";
1919
import { CertificateError } from "./error";
2020
import { getGlobalFlags } from "./globalFlags";
2121
import { type Logger } from "./logging/logger";
22+
import { type CoderOAuthHelper } from "./oauth/oauthHelper";
2223
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
2324
import {
2425
AgentTreeItem,
@@ -48,6 +49,7 @@ export class Commands {
4849
public constructor(
4950
serviceContainer: ServiceContainer,
5051
private readonly restClient: Api,
52+
private readonly oauthHelper: CoderOAuthHelper,
5153
) {
5254
this.vscodeProposed = serviceContainer.getVsCodeProposed();
5355
this.logger = serviceContainer.getLogger();
@@ -182,59 +184,119 @@ export class Commands {
182184
}
183185

184186
/**
185-
* Log into the provided deployment. If the deployment URL is not specified,
186-
* ask for it first with a menu showing recent URLs along with the default URL
187-
* and CODER_URL, if those are set.
187+
* Check if server supports OAuth by attempting to fetch the well-known endpoint.
188188
*/
189-
public async login(args?: {
190-
url?: string;
191-
token?: string;
192-
label?: string;
193-
autoLogin?: boolean;
194-
}): Promise<void> {
195-
if (this.contextManager.get("coder.authenticated")) {
196-
return;
189+
private async checkOAuthSupport(client: CoderApi): Promise<boolean> {
190+
try {
191+
await client
192+
.getAxiosInstance()
193+
.get("/.well-known/oauth-authorization-server");
194+
this.logger.debug("Server supports OAuth");
195+
return true;
196+
} catch (error) {
197+
this.logger.debug("Server does not support OAuth:", error);
198+
return false;
197199
}
198-
this.logger.info("Logging in");
200+
}
199201

200-
const url = await this.maybeAskUrl(args?.url);
201-
if (!url) {
202-
return; // The user aborted.
203-
}
202+
/**
203+
* Ask user to choose between OAuth and legacy API token authentication.
204+
*/
205+
private async askAuthMethod(): Promise<"oauth" | "legacy" | undefined> {
206+
const choice = await vscode.window.showQuickPick(
207+
[
208+
{
209+
label: "$(key) OAuth (Recommended)",
210+
detail: "Secure authentication with automatic token refresh",
211+
value: "oauth",
212+
},
213+
{
214+
label: "$(lock) API Token",
215+
detail: "Use a manually created API key",
216+
value: "legacy",
217+
},
218+
],
219+
{
220+
title: "Choose Authentication Method",
221+
placeHolder: "How would you like to authenticate?",
222+
ignoreFocusOut: true,
223+
},
224+
);
204225

205-
// It is possible that we are trying to log into an old-style host, in which
206-
// case we want to write with the provided blank label instead of generating
207-
// a host label.
208-
const label = args?.label === undefined ? toSafeHost(url) : args.label;
226+
return choice?.value as "oauth" | "legacy" | undefined;
227+
}
209228

210-
// Try to get a token from the user, if we need one, and their user.
211-
const autoLogin = args?.autoLogin === true;
212-
const res = await this.maybeAskToken(url, args?.token, autoLogin);
213-
if (!res) {
214-
return; // The user aborted, or unable to auth.
229+
/**
230+
* Authenticate using OAuth flow.
231+
* Returns the access token and authenticated user, or null if failed/cancelled.
232+
*/
233+
private async loginWithOAuth(
234+
url: string,
235+
): Promise<{ user: User; token: string } | null> {
236+
try {
237+
this.logger.info("Starting OAuth authentication");
238+
239+
// Start OAuth authorization flow
240+
const { code, verifier } = await this.oauthHelper.startAuthorization(url);
241+
242+
// Exchange authorization code for tokens
243+
const tokenResponse = await this.oauthHelper.exchangeToken(
244+
code,
245+
verifier,
246+
);
247+
248+
// Validate token by fetching user
249+
const client = CoderApi.create(
250+
url,
251+
tokenResponse.access_token,
252+
this.logger,
253+
);
254+
const user = await client.getAuthenticatedUser();
255+
256+
this.logger.info("OAuth authentication successful");
257+
258+
return {
259+
token: tokenResponse.access_token,
260+
user,
261+
};
262+
} catch (error) {
263+
this.logger.error("OAuth authentication failed:", error);
264+
vscode.window.showErrorMessage(
265+
`OAuth authentication failed: ${getErrorMessage(error, "Unknown error")}`,
266+
);
267+
return null;
215268
}
269+
}
216270

217-
// The URL is good and the token is either good or not required; authorize
218-
// the global client.
271+
/**
272+
* Complete the login process by storing credentials and updating context.
273+
*/
274+
private async completeLogin(
275+
url: string,
276+
label: string,
277+
token: string,
278+
user: User,
279+
): Promise<void> {
280+
// Authorize the global client
219281
this.restClient.setHost(url);
220-
this.restClient.setSessionToken(res.token);
282+
this.restClient.setSessionToken(token);
221283

222-
// Store these to be used in later sessions.
284+
// Store for later sessions
223285
await this.mementoManager.setUrl(url);
224-
await this.secretsManager.setSessionToken(res.token);
286+
await this.secretsManager.setSessionToken(token);
225287

226-
// Store on disk to be used by the cli.
227-
await this.cliManager.configure(label, url, res.token);
288+
// Store on disk for CLI
289+
await this.cliManager.configure(label, url, token);
228290

229-
// These contexts control various menu items and the sidebar.
291+
// Update contexts
230292
this.contextManager.set("coder.authenticated", true);
231-
if (res.user.roles.find((role) => role.name === "owner")) {
293+
if (user.roles.find((role) => role.name === "owner")) {
232294
this.contextManager.set("coder.isOwner", true);
233295
}
234296

235297
vscode.window
236298
.showInformationMessage(
237-
`Welcome to Coder, ${res.user.username}!`,
299+
`Welcome to Coder, ${user.username}!`,
238300
{
239301
detail:
240302
"You can now use the Coder extension to manage your Coder instance.",
@@ -252,6 +314,73 @@ export class Commands {
252314
vscode.commands.executeCommand("coder.refreshWorkspaces");
253315
}
254316

317+
/**
318+
* Log into the provided deployment. If the deployment URL is not specified,
319+
* ask for it first with a menu showing recent URLs along with the default URL
320+
* and CODER_URL, if those are set.
321+
*/
322+
public async login(args?: {
323+
url?: string;
324+
token?: string;
325+
label?: string;
326+
autoLogin?: boolean;
327+
}): Promise<void> {
328+
if (this.contextManager.get("coder.authenticated")) {
329+
return;
330+
}
331+
this.logger.info("Logging in");
332+
333+
const url = await this.maybeAskUrl(args?.url);
334+
if (!url) {
335+
return; // The user aborted.
336+
}
337+
338+
const label = args?.label ?? toSafeHost(url);
339+
const autoLogin = args?.autoLogin === true;
340+
341+
// Check if we have an existing valid legacy token
342+
const existingToken = await this.secretsManager.getSessionToken();
343+
const client = CoderApi.create(url, existingToken, this.logger);
344+
if (existingToken && !args?.token) {
345+
try {
346+
const user = await client.getAuthenticatedUser();
347+
this.logger.info("Using existing valid session token");
348+
await this.completeLogin(url, label, existingToken, user);
349+
return;
350+
} catch {
351+
this.logger.debug("Existing token invalid, clearing it");
352+
await this.secretsManager.setSessionToken();
353+
}
354+
}
355+
356+
// Check if server supports OAuth
357+
const supportsOAuth = await this.checkOAuthSupport(client);
358+
359+
if (supportsOAuth && !autoLogin) {
360+
const choice = await this.askAuthMethod();
361+
if (!choice) {
362+
return;
363+
}
364+
365+
if (choice === "oauth") {
366+
const res = await this.loginWithOAuth(url);
367+
if (!res) {
368+
return;
369+
}
370+
await this.completeLogin(url, label, res.token, res.user);
371+
return;
372+
}
373+
}
374+
375+
// Use legacy token flow (existing behavior)
376+
const res = await this.maybeAskToken(url, args?.token, autoLogin);
377+
if (!res) {
378+
return;
379+
}
380+
381+
await this.completeLogin(url, label, res.token, res.user);
382+
}
383+
255384
/**
256385
* If necessary, ask for a token, and keep asking until the token has been
257386
* validated. Return the token and user that was fetched to validate the
@@ -377,6 +506,22 @@ export class Commands {
377506
// Sanity check; command should not be available if no url.
378507
throw new Error("You are not logged in");
379508
}
509+
510+
// Check if using OAuth
511+
const hasOAuthTokens = await this.secretsManager.getOAuthTokens();
512+
if (hasOAuthTokens) {
513+
this.logger.info("Logging out via OAuth");
514+
try {
515+
await this.oauthHelper.logout();
516+
} catch (error) {
517+
this.logger.warn(
518+
"OAuth logout failed, continuing with cleanup:",
519+
error,
520+
);
521+
}
522+
}
523+
524+
// Continue with standard logout (clears sessionToken, contexts, etc)
380525
await this.forceLogout();
381526
}
382527

src/core/secretsManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ export class SecretsManager {
8484
});
8585
}
8686

87+
/**
88+
* Listens for session token changes.
89+
*/
90+
public onDidChangeSessionToken(
91+
listener: (token: string | undefined) => Promise<void>,
92+
): Disposable {
93+
return this.secrets.onDidChange(async (e) => {
94+
if (e.key === SESSION_TOKEN_KEY) {
95+
const token = await this.getSessionToken();
96+
await listener(token);
97+
}
98+
});
99+
}
100+
87101
/**
88102
* Store OAuth client registration data.
89103
*/

src/extension.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,34 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
124124
);
125125

126126
const oauthHelper = await activateCoderOAuth(
127-
client,
127+
url || "",
128128
secretsManager,
129129
output,
130130
ctx,
131131
);
132+
ctx.subscriptions.push(oauthHelper);
133+
134+
// Listen for session token changes and sync state across all components
135+
ctx.subscriptions.push(
136+
secretsManager.onDidChangeSessionToken(async (token) => {
137+
if (!token) {
138+
output.debug("Session token cleared");
139+
client.setSessionToken("");
140+
return;
141+
}
142+
143+
output.debug("Session token changed, syncing state");
144+
145+
client.setSessionToken(token);
146+
const url = mementoManager.getUrl();
147+
if (url) {
148+
const cliManager = serviceContainer.getCliManager();
149+
// TODO label might not match?
150+
await cliManager.configure(toSafeHost(url), url, token);
151+
output.debug("Updated CLI config with new token");
152+
}
153+
}),
154+
);
132155

133156
// Handle vscode:// URIs.
134157
const uriHandler = vscode.window.registerUriHandler({
@@ -293,7 +316,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
293316

294317
// Register globally available commands. Many of these have visibility
295318
// controlled by contexts, see `when` in the package.json.
296-
const commands = new Commands(serviceContainer, client);
319+
const commands = new Commands(serviceContainer, client, oauthHelper);
297320
ctx.subscriptions.push(
298321
vscode.commands.registerCommand(
299322
"coder.login",

0 commit comments

Comments
 (0)