@@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager";
1919import { CertificateError } from "./error" ;
2020import { getGlobalFlags } from "./globalFlags" ;
2121import { type Logger } from "./logging/logger" ;
22+ import { type CoderOAuthHelper } from "./oauth/oauthHelper" ;
2223import { escapeCommandArg , toRemoteAuthority , toSafeHost } from "./util" ;
2324import {
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
0 commit comments