@@ -264,6 +264,7 @@ fn resolve_auth_url(request: &LoginRequest) -> Result<String, CliError> {
264264 . clone ( )
265265 . or_else ( || std:: env:: var ( "STACKER_AUTH_URL" ) . ok ( ) )
266266 . or_else ( || std:: env:: var ( "STACKER_API_URL" ) . ok ( ) )
267+ . or_else ( || crate :: cli:: user_config:: UserConfig :: load ( ) . auth_url )
267268 . ok_or_else ( || {
268269 CliError :: ConfigValidation (
269270 "Missing auth URL. Pass `stacker login --auth-url <user-service-url> --server-url <stacker-api-url>` or set STACKER_AUTH_URL (or STACKER_API_URL) and STACKER_URL." . to_string ( ) ,
@@ -276,6 +277,7 @@ fn resolve_server_url(request: &LoginRequest) -> Result<String, CliError> {
276277 . server_url
277278 . clone ( )
278279 . or_else ( || std:: env:: var ( "STACKER_URL" ) . ok ( ) )
280+ . or_else ( || crate :: cli:: user_config:: UserConfig :: load ( ) . server_url )
279281 . map ( |value| crate :: cli:: install_runner:: normalize_stacker_server_url ( & value) )
280282 . ok_or_else ( || {
281283 CliError :: ConfigValidation (
@@ -404,6 +406,257 @@ impl fmt::Display for StoredCredentials {
404406 }
405407}
406408
409+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
410+ // RFC 8628 Device Authorization Grant helpers
411+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
412+
413+ /// Strip a trailing `/auth/login` suffix so we always work from the user-service
414+ /// base URL (e.g. `https://try.direct/server/user`).
415+ fn oauth_base_url ( auth_url : & str ) -> String {
416+ let url = auth_url. trim_end_matches ( '/' ) ;
417+ for suffix in & [ "/auth/login" , "/server/user/auth/login" ] {
418+ if let Some ( base) = url. strip_suffix ( suffix) {
419+ return base. to_string ( ) ;
420+ }
421+ }
422+ url. to_string ( )
423+ }
424+
425+ /// Response from `POST /oauth_client/device_authorization`.
426+ struct DeviceAuthResponse {
427+ device_code : String ,
428+ user_code : String ,
429+ verification_uri : String ,
430+ verification_uri_complete : String ,
431+ expires_in : u64 ,
432+ interval : u64 ,
433+ }
434+
435+ /// RFC 8628 §3.1 — Device Authorization Request.
436+ ///
437+ /// POSTs `{"client_id": "stacker-cli", "provider": "<gc|gh|...>"}` and returns
438+ /// the server-generated codes the CLI needs to display and poll with.
439+ fn request_device_authorization (
440+ base_url : & str ,
441+ provider : & str ,
442+ ) -> Result < DeviceAuthResponse , CliError > {
443+ let endpoint = format ! ( "{base_url}/oauth_client/device_authorization" ) ;
444+
445+ let client = reqwest:: blocking:: Client :: builder ( )
446+ . timeout ( std:: time:: Duration :: from_secs ( 30 ) )
447+ . build ( )
448+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "HTTP client error: {e}" ) ) ) ?;
449+
450+ let body = serde_json:: json!( { "client_id" : "stacker-cli" , "provider" : provider } ) ;
451+ let resp = client
452+ . post ( & endpoint)
453+ . json ( & body)
454+ . send ( )
455+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "Network error: {e}" ) ) ) ?;
456+
457+ if !resp. status ( ) . is_success ( ) {
458+ let status = resp. status ( ) ;
459+ let preview: String = resp. text ( ) . unwrap_or_default ( ) . chars ( ) . take ( 200 ) . collect ( ) ;
460+ return Err ( CliError :: AuthFailed ( format ! (
461+ "Device authorization failed ({status}): {preview}"
462+ ) ) ) ;
463+ }
464+
465+ let data: serde_json:: Value = resp
466+ . json ( )
467+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "Invalid response: {e}" ) ) ) ?;
468+ let inner = data. get ( "data" ) . unwrap_or ( & data) ;
469+
470+ let field = |key : & str | -> Result < String , CliError > {
471+ inner
472+ . get ( key)
473+ . and_then ( |v| v. as_str ( ) )
474+ . map ( |s| s. to_string ( ) )
475+ . ok_or_else ( || CliError :: AuthFailed ( format ! ( "Response missing `{key}`: {data}" ) ) )
476+ } ;
477+
478+ Ok ( DeviceAuthResponse {
479+ device_code : field ( "device_code" ) ?,
480+ user_code : field ( "user_code" ) ?,
481+ verification_uri : field ( "verification_uri" ) ?,
482+ verification_uri_complete : field ( "verification_uri_complete" ) ?,
483+ expires_in : inner. get ( "expires_in" ) . and_then ( |v| v. as_u64 ( ) ) . unwrap_or ( 300 ) ,
484+ interval : inner. get ( "interval" ) . and_then ( |v| v. as_u64 ( ) ) . unwrap_or ( 5 ) ,
485+ } )
486+ }
487+
488+ /// RFC 8628 §3.4 — Device Access Token Request (polling).
489+ ///
490+ /// Polls `POST /oauth_client/device_token` every `interval` seconds.
491+ /// Handles `authorization_pending` (keep waiting), `slow_down` (+5 s),
492+ /// `access_denied`, and `expired_token` per the spec.
493+ fn poll_device_token (
494+ base_url : & str ,
495+ device_code : & str ,
496+ interval_secs : u64 ,
497+ expires_in : u64 ,
498+ ) -> Result < ( String , Option < String > , Option < u64 > ) , CliError > {
499+ let endpoint = format ! ( "{base_url}/oauth_client/device_token" ) ;
500+
501+ let client = reqwest:: blocking:: Client :: builder ( )
502+ . timeout ( std:: time:: Duration :: from_secs ( 15 ) )
503+ . build ( )
504+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "HTTP client error: {e}" ) ) ) ?;
505+
506+ let body = serde_json:: json!( {
507+ "grant_type" : "urn:ietf:params:oauth:grant-type:device_code" ,
508+ "device_code" : device_code,
509+ "client_id" : "stacker-cli" ,
510+ } ) ;
511+
512+ let deadline = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( expires_in) ;
513+ let mut interval = std:: time:: Duration :: from_secs ( interval_secs) ;
514+
515+ loop {
516+ std:: thread:: sleep ( interval) ;
517+
518+ if std:: time:: Instant :: now ( ) >= deadline {
519+ return Err ( CliError :: AuthFailed (
520+ "Authentication timed out. Please try again." . to_string ( ) ,
521+ ) ) ;
522+ }
523+
524+ let resp = match client. post ( & endpoint) . json ( & body) . send ( ) {
525+ Ok ( r) => r,
526+ Err ( _) => continue , // transient network error — keep polling
527+ } ;
528+
529+ let status = resp. status ( ) ;
530+ let data: serde_json:: Value = resp. json ( ) . unwrap_or ( serde_json:: Value :: Null ) ;
531+
532+ if status. is_success ( ) {
533+ let access_token = data
534+ . get ( "access_token" )
535+ . and_then ( |v| v. as_str ( ) )
536+ . ok_or_else ( || {
537+ CliError :: AuthFailed ( "Token response missing access_token" . to_string ( ) )
538+ } ) ?
539+ . to_string ( ) ;
540+ let refresh_token = data
541+ . get ( "refresh_token" )
542+ . and_then ( |v| v. as_str ( ) )
543+ . map ( |s| s. to_string ( ) ) ;
544+ let token_expires_in = data. get ( "expires_in" ) . and_then ( |v| v. as_u64 ( ) ) ;
545+ return Ok ( ( access_token, refresh_token, token_expires_in) ) ;
546+ }
547+
548+ // RFC 8628 §3.5 error codes
549+ match data. get ( "error" ) . and_then ( |v| v. as_str ( ) ) {
550+ Some ( "authorization_pending" ) => { } // normal — keep polling
551+ Some ( "slow_down" ) => interval += std:: time:: Duration :: from_secs ( 5 ) ,
552+ Some ( "access_denied" ) => {
553+ return Err ( CliError :: AuthFailed ( "Access denied by user." . to_string ( ) ) )
554+ }
555+ Some ( "expired_token" ) => {
556+ return Err ( CliError :: AuthFailed (
557+ "Session expired. Please run `stacker login` again." . to_string ( ) ,
558+ ) )
559+ }
560+ Some ( other) => {
561+ return Err ( CliError :: AuthFailed ( format ! ( "Auth error: {other}" ) ) )
562+ }
563+ None => { } // unexpected non-200 without error field — keep polling
564+ }
565+ }
566+ }
567+
568+ /// Retrieve the authenticated user's email from the user service.
569+ pub fn fetch_user_email ( auth_url : & str , access_token : & str ) -> Result < Option < String > , CliError > {
570+ let base = oauth_base_url ( auth_url) ;
571+ let endpoint = format ! ( "{base}/oauth_server/api/me" ) ;
572+
573+ let client = reqwest:: blocking:: Client :: builder ( )
574+ . timeout ( std:: time:: Duration :: from_secs ( 15 ) )
575+ . build ( )
576+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "HTTP client error: {e}" ) ) ) ?;
577+
578+ let resp = client
579+ . get ( & endpoint)
580+ . bearer_auth ( access_token)
581+ . send ( )
582+ . map_err ( |e| CliError :: AuthFailed ( format ! ( "Network error fetching user profile: {e}" ) ) ) ?;
583+
584+ if !resp. status ( ) . is_success ( ) {
585+ return Ok ( None ) ;
586+ }
587+
588+ let data: serde_json:: Value = resp. json ( ) . unwrap_or ( serde_json:: Value :: Null ) ;
589+ // /oauth_server/api/me returns {"user": {"email": ...}}
590+ let email = data. get ( "email" )
591+ . or_else ( || data. get ( "user" ) . and_then ( |u| u. get ( "email" ) ) )
592+ . and_then ( |v| v. as_str ( ) )
593+ . map ( |s| s. to_string ( ) ) ;
594+ Ok ( email)
595+ }
596+
597+ /// RFC 8628 Device Authorization Grant login flow.
598+ ///
599+ /// 1. Requests device + user codes from the server.
600+ /// 2. Shows the user_code and opens the browser to the OAuth URL.
601+ /// 3. Polls until the user authenticates or the session expires.
602+ /// 4. Saves and returns the credentials.
603+ pub fn browser_login < S : CredentialStore > (
604+ store : & CredentialsManager < S > ,
605+ auth_url : & str ,
606+ server_url : & str ,
607+ provider : & str ,
608+ org : Option < & str > ,
609+ domain : Option < & str > ,
610+ ) -> Result < StoredCredentials , CliError > {
611+ let base = oauth_base_url ( auth_url) ;
612+ let device_auth = request_device_authorization ( & base, provider) ?;
613+
614+ eprintln ! ( "\n To sign in, open this URL in your browser:" ) ;
615+ eprintln ! ( " {}" , device_auth. verification_uri_complete) ;
616+ eprintln ! ( ) ;
617+
618+ let opened = {
619+ #[ cfg( target_os = "macos" ) ]
620+ { std:: process:: Command :: new ( "open" ) . arg ( & device_auth. verification_uri_complete ) . status ( ) . is_ok ( ) }
621+ #[ cfg( target_os = "linux" ) ]
622+ { std:: process:: Command :: new ( "xdg-open" ) . arg ( & device_auth. verification_uri_complete ) . status ( ) . is_ok ( ) }
623+ #[ cfg( not( any( target_os = "macos" , target_os = "linux" ) ) ) ]
624+ { false }
625+ } ;
626+ if opened {
627+ eprintln ! ( " (Browser opened automatically)" ) ;
628+ }
629+
630+ eprintln ! ( "Waiting for authentication..." ) ;
631+
632+ let ( access_token, refresh_token, token_expires_in) = poll_device_token (
633+ & base,
634+ & device_auth. device_code ,
635+ device_auth. interval ,
636+ device_auth. expires_in ,
637+ ) ?;
638+
639+ let email = fetch_user_email ( auth_url, & access_token) ?;
640+
641+ let min_ttl = session_ttl_secs ( ) ;
642+ let ttl = token_expires_in. unwrap_or ( min_ttl) . max ( min_ttl) ;
643+ let expires_at = chrono:: Utc :: now ( ) + chrono:: Duration :: seconds ( ttl as i64 ) ;
644+
645+ let creds = StoredCredentials {
646+ access_token,
647+ refresh_token,
648+ token_type : "Bearer" . to_string ( ) ,
649+ expires_at,
650+ email,
651+ server_url : Some ( crate :: cli:: install_runner:: normalize_stacker_server_url ( server_url) ) ,
652+ org : org. map ( |s| s. to_string ( ) ) ,
653+ domain : domain. map ( |s| s. to_string ( ) ) ,
654+ } ;
655+
656+ store. save ( & creds) ?;
657+ Ok ( creds)
658+ }
659+
407660// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
408661// Tests
409662// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0 commit comments