1+ import { isAxiosError } from "axios" ;
12import { getErrorMessage } from "coder/site/src/api/errors" ;
23import * as vscode from "vscode" ;
34
@@ -13,11 +14,9 @@ import type { SecretsManager } from "../core/secretsManager";
1314import type { Deployment } from "../deployment/types" ;
1415import type { Logger } from "../logging/logger" ;
1516
16- interface LoginResult {
17- success : boolean ;
18- user ?: User ;
19- token ?: string ;
20- }
17+ type LoginResult =
18+ | { success : false }
19+ | { success : true ; user ?: User ; token : string } ;
2120
2221interface LoginOptions {
2322 safeHostname : string ;
@@ -42,7 +41,7 @@ export class LoginCoordinator {
4241 * Direct login - for user-initiated login via commands.
4342 * Stores session auth and URL history on success.
4443 */
45- public async promptForLogin (
44+ public async ensureLoggedIn (
4645 options : LoginOptions & { url : string } ,
4746 ) : Promise < LoginResult > {
4847 const { safeHostname, url } = options ;
@@ -61,11 +60,11 @@ export class LoginCoordinator {
6160 /**
6261 * Shows dialog then login - for system-initiated auth (remote).
6362 */
64- public async promptForLoginWithDialog (
63+ public async ensureLoggedInWithDialog (
6564 options : LoginOptions & { message ?: string ; detailPrefix ?: string } ,
6665 ) : Promise < LoginResult > {
6766 const { safeHostname, url, detailPrefix, message } = options ;
68- return this . executeWithGuard ( safeHostname , ( ) => {
67+ return this . executeWithGuard ( safeHostname , async ( ) => {
6968 // Show dialog promise
7069 const dialogPromise = this . vscodeProposed . window
7170 . showErrorMessage (
@@ -103,15 +102,20 @@ export class LoginCoordinator {
103102 return result ;
104103 } else {
105104 // User cancelled
106- return { success : false } ;
105+ return { success : false } as const ;
107106 }
108107 } ) ;
109108
110109 // Race between user clicking login and cross-window detection
111- return Promise . race ( [
112- dialogPromise ,
113- this . waitForCrossWindowLogin ( safeHostname ) ,
114- ] ) ;
110+ const {
111+ promise : crossWindowPromise ,
112+ dispose : disposeCrossWindowListener ,
113+ } = this . waitForCrossWindowLogin ( safeHostname ) ;
114+ try {
115+ return await Promise . race ( [ dialogPromise , crossWindowPromise ] ) ;
116+ } finally {
117+ disposeCrossWindowListener ( ) ;
118+ }
115119 } ) ;
116120 }
117121
@@ -121,7 +125,7 @@ export class LoginCoordinator {
121125 url : string ,
122126 ) : Promise < void > {
123127 // Empty token is valid for mTLS
124- if ( result . success && result . token !== undefined ) {
128+ if ( result . success ) {
125129 await this . secretsManager . setSessionAuth ( safeHostname , {
126130 url,
127131 token : result . token ,
@@ -154,21 +158,28 @@ export class LoginCoordinator {
154158
155159 /**
156160 * Waits for login detected from another window.
161+ * Returns a promise and a dispose function to clean up the listener.
157162 */
158- private async waitForCrossWindowLogin (
159- safeHostname : string ,
160- ) : Promise < LoginResult > {
161- return new Promise ( ( resolve ) => {
162- const disposable = this . secretsManager . onDidChangeSessionAuth (
163+ private waitForCrossWindowLogin ( safeHostname : string ) : {
164+ promise : Promise < LoginResult > ;
165+ dispose : ( ) => void ;
166+ } {
167+ let disposable : vscode . Disposable | undefined ;
168+ const promise = new Promise < LoginResult > ( ( resolve ) => {
169+ disposable = this . secretsManager . onDidChangeSessionAuth (
163170 safeHostname ,
164171 ( auth ) => {
165172 if ( auth ?. token ) {
166- disposable . dispose ( ) ;
173+ disposable ? .dispose ( ) ;
167174 resolve ( { success : true , token : auth . token } ) ;
168175 }
169176 } ,
170177 ) ;
171178 } ) ;
179+ return {
180+ promise,
181+ dispose : ( ) => disposable ?. dispose ( ) ,
182+ } ;
172183 }
173184
174185 /**
@@ -197,15 +208,18 @@ export class LoginCoordinator {
197208
198209 // Attempt authentication with current credentials (token or mTLS)
199210 try {
200- const user = await client . getAuthenticatedUser ( ) ;
201- // Return the token that was used (empty string for mTLS since
202- // the `vscodessh` command currently always requires a token file)
203- return { success : true , token : storedToken ?? "" , user } ;
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 } ;
216+ }
204217 } catch ( err ) {
205- if ( needsToken ) {
206- // For token auth: silently continue to prompt for new credentials
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
207221 } else {
208- // For mTLS: show error and abort (no credentials to prompt for)
222+ // For mTLS or non-401 errors : show error and abort
209223 const message = getErrorMessage ( err , "no response from the server" ) ;
210224 if ( isAutoLogin ) {
211225 this . logger . warn ( "Failed to log in to Coder server:" , message ) ;
0 commit comments