Skip to content

Commit c059503

Browse files
committed
Only set session auth if the token is valid
* Always return User if the login is successful
1 parent ab50022 commit c059503

File tree

6 files changed

+250
-64
lines changed

6 files changed

+250
-64
lines changed

src/commands.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,6 @@ export class Commands {
111111
return;
112112
}
113113

114-
// Login might have happened in another process/window so we do not have the user yet.
115-
result.user ??= await this.extensionClient.getAuthenticatedUser();
116-
117114
await this.deploymentManager.setDeployment({
118115
url,
119116
safeHostname,

src/deployment/deploymentManager.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,8 @@ export class DeploymentManager implements vscode.Disposable {
8383
* Returns true if deployment was changed, false otherwise.
8484
*/
8585
public async setDeploymentIfValid(
86-
deployment: DeploymentWithAuth,
86+
deployment: Deployment & { token?: string },
8787
): Promise<boolean> {
88-
if (deployment.user) {
89-
await this.setDeployment({ ...deployment, user: deployment.user });
90-
return true;
91-
}
92-
9388
const auth = await this.secretsManager.getSessionAuth(
9489
deployment.safeHostname,
9590
);

src/login/loginCoordinator.ts

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import type { Logger } from "../logging/logger";
1616

1717
type LoginResult =
1818
| { success: false }
19-
| { success: true; user?: User; token: string };
19+
| { success: true; user: User; token: string };
2020

2121
interface LoginOptions {
2222
safeHostname: string;
2323
url: string | undefined;
2424
autoLogin?: boolean;
25+
token?: string;
2526
}
2627

2728
/**
@@ -49,6 +50,7 @@ export class LoginCoordinator {
4950
const result = await this.attemptLogin(
5051
{ safeHostname, url },
5152
options.autoLogin ?? false,
53+
options.token,
5254
);
5355

5456
await this.persistSessionAuth(result, safeHostname, url);
@@ -95,6 +97,7 @@ export class LoginCoordinator {
9597
const result = await this.attemptLogin(
9698
{ url: newUrl, safeHostname },
9799
false,
100+
options.token,
98101
);
99102

100103
await this.persistSessionAuth(result, safeHostname, newUrl);
@@ -168,10 +171,17 @@ export class LoginCoordinator {
168171
const promise = new Promise<LoginResult>((resolve) => {
169172
disposable = this.secretsManager.onDidChangeSessionAuth(
170173
safeHostname,
171-
(auth) => {
174+
async (auth) => {
172175
if (auth?.token) {
173176
disposable?.dispose();
174-
resolve({ success: true, token: auth.token });
177+
const client = CoderApi.create(auth.url, auth.token, this.logger);
178+
try {
179+
const user = await client.getAuthenticatedUser();
180+
resolve({ success: true, token: auth.token, user });
181+
} catch {
182+
// Token from other window was invalid, ignore and keep waiting
183+
// (or user can click Login/Cancel in the dialog)
184+
}
175185
}
176186
},
177187
);
@@ -191,54 +201,93 @@ export class LoginCoordinator {
191201
private async attemptLogin(
192202
deployment: Deployment,
193203
isAutoLogin: boolean,
204+
providedToken?: string,
194205
): Promise<LoginResult> {
195-
const needsToken = needToken(vscode.workspace.getConfiguration());
196206
const client = CoderApi.create(deployment.url, "", this.logger);
197207

198-
let storedToken: string | undefined;
199-
if (needsToken) {
200-
const auth = await this.secretsManager.getSessionAuth(
201-
deployment.safeHostname,
208+
// mTLS authentication (no token needed)
209+
if (!needToken(vscode.workspace.getConfiguration())) {
210+
return this.tryMtlsAuth(client, isAutoLogin);
211+
}
212+
213+
// Try provided token first
214+
if (providedToken) {
215+
const result = await this.tryTokenAuth(
216+
client,
217+
providedToken,
218+
isAutoLogin,
202219
);
203-
storedToken = auth?.token;
204-
if (storedToken) {
205-
client.setSessionToken(storedToken);
220+
if (result !== "retry") {
221+
return result;
206222
}
207223
}
208224

209-
// Attempt authentication with current credentials (token or mTLS)
210-
try {
211-
if (!needsToken || storedToken) {
212-
const user = await client.getAuthenticatedUser();
213-
// Return the token that was used (empty string for mTLS since
214-
// the `vscodessh` command currently always requires a token file)
215-
return { success: true, token: storedToken ?? "", user };
225+
// Try stored token (skip if same as provided)
226+
const auth = await this.secretsManager.getSessionAuth(
227+
deployment.safeHostname,
228+
);
229+
if (auth?.token && auth.token !== providedToken) {
230+
const result = await this.tryTokenAuth(client, auth.token, isAutoLogin);
231+
if (result !== "retry") {
232+
return result;
216233
}
234+
}
235+
236+
// Prompt user for token
237+
return this.loginWithToken(client);
238+
}
239+
240+
private async tryMtlsAuth(
241+
client: CoderApi,
242+
isAutoLogin: boolean,
243+
): Promise<LoginResult> {
244+
try {
245+
const user = await client.getAuthenticatedUser();
246+
return { success: true, token: "", user };
217247
} catch (err) {
218-
const is401 = isAxiosError(err) && err.response?.status === 401;
219-
if (needsToken && is401) {
220-
// For token auth with 401: silently continue to prompt for new credentials
221-
} else {
222-
// For mTLS or non-401 errors: show error and abort
223-
const message = getErrorMessage(err, "no response from the server");
224-
if (isAutoLogin) {
225-
this.logger.warn("Failed to log in to Coder server:", message);
226-
} else {
227-
this.vscodeProposed.window.showErrorMessage(
228-
"Failed to log in to Coder server",
229-
{
230-
detail: message,
231-
modal: true,
232-
useCustom: true,
233-
},
234-
);
235-
}
236-
return { success: false };
248+
this.showAuthError(err, isAutoLogin);
249+
return { success: false };
250+
}
251+
}
252+
253+
/**
254+
* Returns 'retry' on 401 to signal trying next token.
255+
*/
256+
private async tryTokenAuth(
257+
client: CoderApi,
258+
token: string,
259+
isAutoLogin: boolean,
260+
): Promise<LoginResult | "retry"> {
261+
client.setSessionToken(token);
262+
try {
263+
const user = await client.getAuthenticatedUser();
264+
return { success: true, token, user };
265+
} catch (err) {
266+
if (isAxiosError(err) && err.response?.status === 401) {
267+
return "retry";
237268
}
269+
this.showAuthError(err, isAutoLogin);
270+
return { success: false };
238271
}
272+
}
239273

240-
const result = await this.loginWithToken(client);
241-
return result;
274+
/**
275+
* Shows auth error via dialog or logs it for autoLogin.
276+
*/
277+
private showAuthError(err: unknown, isAutoLogin: boolean): void {
278+
const message = getErrorMessage(err, "no response from the server");
279+
if (isAutoLogin) {
280+
this.logger.warn("Failed to log in to Coder server:", message);
281+
} else {
282+
this.vscodeProposed.window.showErrorMessage(
283+
"Failed to log in to Coder server",
284+
{
285+
detail: message,
286+
modal: true,
287+
useCustom: true,
288+
},
289+
);
290+
}
242291
}
243292

244293
/**

src/uri/uriHandler.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,21 +159,18 @@ async function setupDeployment(
159159

160160
const safeHostname = toSafeHost(url);
161161

162-
const token: string | null = params.get("token");
163-
if (token !== null) {
164-
await secretsManager.setSessionAuth(safeHostname, { url, token });
165-
}
166-
162+
const token: string | undefined = params.get("token") ?? undefined;
167163
const result = await loginCoordinator.ensureLoggedIn({
168164
safeHostname,
169165
url,
166+
token,
170167
});
171168

172169
if (!result.success) {
173170
throw new Error("Failed to login to deployment from URI");
174171
}
175172

176-
await deploymentManager.setDeploymentIfValid({
173+
await deploymentManager.setDeployment({
177174
safeHostname,
178175
url,
179176
token: result.token,

test/unit/login/loginCoordinator.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,4 +331,142 @@ describe("LoginCoordinator", () => {
331331
expect(result.success).toBe(false);
332332
});
333333
});
334+
335+
describe("token fallback order", () => {
336+
it("uses provided token first when valid", async () => {
337+
const { secretsManager, coordinator, mockSuccessfulAuth } =
338+
createTestContext();
339+
const user = mockSuccessfulAuth();
340+
341+
// Store a different token
342+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
343+
url: TEST_URL,
344+
token: "stored-token",
345+
});
346+
347+
const result = await coordinator.ensureLoggedIn({
348+
url: TEST_URL,
349+
safeHostname: TEST_HOSTNAME,
350+
token: "provided-token",
351+
});
352+
353+
expect(result).toEqual({ success: true, user, token: "provided-token" });
354+
});
355+
356+
it("falls back to stored token when provided token is invalid", async () => {
357+
const { mockAdapter, secretsManager, coordinator } = createTestContext();
358+
const user = createMockUser();
359+
360+
mockAdapter
361+
.mockRejectedValueOnce({
362+
isAxiosError: true,
363+
response: { status: 401 }, // Fail the provided token with 401
364+
message: "Unauthorized",
365+
})
366+
.mockResolvedValueOnce({
367+
data: user,
368+
status: 200, // Succeed the stored token
369+
headers: {},
370+
config: {},
371+
});
372+
373+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
374+
url: TEST_URL,
375+
token: "stored-token",
376+
});
377+
378+
const result = await coordinator.ensureLoggedIn({
379+
url: TEST_URL,
380+
safeHostname: TEST_HOSTNAME,
381+
token: "invalid-provided-token",
382+
});
383+
384+
expect(result).toEqual({ success: true, user, token: "stored-token" });
385+
});
386+
387+
it("prompts user when both provided and stored tokens are invalid", async () => {
388+
const { mockAdapter, userInteraction, secretsManager, coordinator } =
389+
createTestContext();
390+
const user = createMockUser();
391+
392+
mockAdapter
393+
.mockRejectedValueOnce({
394+
isAxiosError: true,
395+
response: { status: 401 }, // provided token
396+
message: "Unauthorized",
397+
})
398+
.mockRejectedValueOnce({
399+
isAxiosError: true,
400+
response: { status: 401 }, // stored token
401+
message: "Unauthorized",
402+
})
403+
.mockResolvedValueOnce({
404+
data: user,
405+
status: 200, // user-entered token
406+
headers: {},
407+
config: {},
408+
});
409+
410+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
411+
url: TEST_URL,
412+
token: "stored-token",
413+
});
414+
415+
userInteraction.setInputBoxValue("user-entered-token");
416+
417+
const result = await coordinator.ensureLoggedIn({
418+
url: TEST_URL,
419+
safeHostname: TEST_HOSTNAME,
420+
token: "invalid-provided-token",
421+
});
422+
423+
expect(result).toEqual({
424+
success: true,
425+
user,
426+
token: "user-entered-token",
427+
});
428+
expect(vscode.window.showInputBox).toHaveBeenCalled();
429+
});
430+
431+
it("skips stored token check when same as provided token", async () => {
432+
const { mockAdapter, userInteraction, secretsManager, coordinator } =
433+
createTestContext();
434+
const user = createMockUser();
435+
436+
mockAdapter
437+
.mockRejectedValueOnce({
438+
isAxiosError: true,
439+
response: { status: 401 }, // provided token
440+
message: "Unauthorized",
441+
})
442+
.mockResolvedValueOnce({
443+
data: user,
444+
status: 200, // user-entered token
445+
headers: {},
446+
config: {},
447+
});
448+
449+
// Store the SAME token as will be provided
450+
await secretsManager.setSessionAuth(TEST_HOSTNAME, {
451+
url: TEST_URL,
452+
token: "same-token",
453+
});
454+
455+
userInteraction.setInputBoxValue("user-entered-token");
456+
457+
const result = await coordinator.ensureLoggedIn({
458+
url: TEST_URL,
459+
safeHostname: TEST_HOSTNAME,
460+
token: "same-token",
461+
});
462+
463+
expect(result).toEqual({
464+
success: true,
465+
user,
466+
token: "user-entered-token",
467+
});
468+
// Provided/stored token check only called once + user prompt
469+
expect(mockAdapter).toHaveBeenCalledTimes(2);
470+
});
471+
});
334472
});

0 commit comments

Comments
 (0)