@@ -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 {
2425AgentTreeItem ,
@@ -48,6 +49,7 @@ export class Commands {
4849public constructor (
4950serviceContainer :ServiceContainer ,
5051private readonly restClient :Api ,
52+ private readonly oauthHelper :CoderOAuthHelper ,
5153) {
5254this . vscodeProposed = serviceContainer . getVsCodeProposed ( ) ;
5355this . 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
219281this . restClient . setHost ( url ) ;
220- this . restClient . setSessionToken ( res . token ) ;
282+ this . restClient . setSessionToken ( token ) ;
221283
222- // Storethese to be used in later sessions.
284+ // Storefor later sessions
223285await this . mementoManager . setUrl ( url ) ;
224- await this . secretsManager . setSessionToken ( res . token ) ;
286+ await this . secretsManager . setSessionToken ( token ) ;
225287
226- // Store on diskto be used by the cli.
227- await this . cliManager . configure ( label , url , res . token ) ;
288+ // Store on diskfor CLI
289+ await this . cliManager . configure ( label , url , token ) ;
228290
229- //These contexts control various menu items and the sidebar.
291+ //Update contexts
230292this . contextManager . set ( "coder.authenticated" , true ) ;
231- if ( res . user . roles . find ( ( role ) => role . name === "owner" ) ) {
293+ if ( user . roles . find ( ( role ) => role . name === "owner" ) ) {
232294this . contextManager . set ( "coder.isOwner" , true ) ;
233295}
234296
235297vscode . window
236298. showInformationMessage (
237- `Welcome to Coder,${ res . user . username } !` ,
299+ `Welcome to Coder,${ user . username } !` ,
238300{
239301detail :
240302"You can now use the Coder extension to manage your Coder instance." ,
@@ -252,6 +314,73 @@ export class Commands {
252314vscode . 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.
378507throw 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)
380525await this . forceLogout ( ) ;
381526}
382527