11import { type Api } from "coder/site/src/api/api" ;
2- import { getErrorMessage } from "coder/site/src/api/errors" ;
32import {
4- type User ,
53 type Workspace ,
64 type WorkspaceAgent ,
75} from "coder/site/src/api/typesGenerated" ;
86import * as vscode from "vscode" ;
97
108import { createWorkspaceIdentifier , extractAgents } from "./api/api-helper" ;
11- import { CoderApi } from "./api/coderApi" ;
12- import { needToken } from "./api/utils" ;
139import { type CliManager } from "./core/cliManager" ;
1410import { type ServiceContainer } from "./core/container" ;
1511import { type ContextManager } from "./core/contextManager" ;
@@ -19,8 +15,9 @@ import { type SecretsManager } from "./core/secretsManager";
1915import { CertificateError } from "./error" ;
2016import { getGlobalFlags } from "./globalFlags" ;
2117import { type Logger } from "./logging/logger" ;
18+ import { type LoginCoordinator } from "./login/loginCoordinator" ;
2219import { type OAuthSessionManager } from "./oauth/sessionManager" ;
23- import { maybeAskAgent , maybeAskUrl , maybeAskAuthMethod } from "./promptUtils" ;
20+ import { maybeAskAgent , maybeAskUrl } from "./promptUtils" ;
2421import { escapeCommandArg , toRemoteAuthority , toSafeHost } from "./util" ;
2522import {
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 /**
0 commit comments