@@ -8,10 +8,13 @@ import (
88 "encoding/json"
99 "errors"
1010 "fmt"
11+ "io"
1112 "net"
1213 "net/http"
14+ "net/url"
1315 "os"
1416 "strings"
17+ "time"
1518
1619 kubectlcmd "k8s.io/kubectl/pkg/cmd"
1720
@@ -25,7 +28,7 @@ import (
2528)
2629
2730const (
28- stagingClientID = "325848904128073754 " // Client ID for staging
31+ stagingClientID = "360304563007327549 " // Client ID for staging
2932 prodClientID = "328728232771788043" // Client ID for prod
3033 redirectPath = "/datumctl/auth/callback"
3134 // Listen on a random port
3639 hostname string // Variable to store hostname flag
3740 apiHostname string // Variable to store api-hostname flag
3841 clientIDFlag string // Variable to store client-id flag
42+ noBrowser bool // Variable to store no-browser flag
3943)
4044
4145var LoginCmd = & cobra.Command {
@@ -53,7 +57,7 @@ var LoginCmd = &cobra.Command{
5357 // Return an error if no client ID could be determined
5458 return fmt .Errorf ("client ID not configured for hostname '%s'. Please specify one with the --client-id flag" , hostname )
5559 }
56- return runLoginFlow (cmd .Context (), hostname , apiHostname , actualClientID , (kubectlcmd .GetLogVerbosity (os .Args ) != "0" ))
60+ return runLoginFlow (cmd .Context (), hostname , apiHostname , actualClientID , noBrowser , (kubectlcmd .GetLogVerbosity (os .Args ) != "0" ))
5761 },
5862}
5963
@@ -64,6 +68,8 @@ func init() {
6468 LoginCmd .Flags ().StringVar (& apiHostname , "api-hostname" , "" , "Hostname of the Datum Cloud API server (if not specified, will be derived from auth hostname)" )
6569 // Add the client-id flag
6670 LoginCmd .Flags ().StringVar (& clientIDFlag , "client-id" , "" , "Override the OAuth2 Client ID" )
71+ // Add the no-browser flag
72+ LoginCmd .Flags ().BoolVar (& noBrowser , "no-browser" , false , "Do not open a browser; use the device authorization flow" )
6773}
6874
6975// Generates a random PKCE code verifier
@@ -94,7 +100,7 @@ func generateRandomState(length int) (string, error) {
94100}
95101
96102// runLoginFlow now accepts context, hostname, apiHostname, clientID, and verbose flag
97- func runLoginFlow (ctx context.Context , authHostname string , apiHostname string , clientID string , verbose bool ) error {
103+ func runLoginFlow (ctx context.Context , authHostname string , apiHostname string , clientID string , noBrowser bool , verbose bool ) error {
98104 fmt .Printf ("Starting login process for %s ...\n " , authHostname )
99105
100106 // Determine the final API hostname to use
@@ -122,6 +128,14 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
122128 // Define scopes
123129 scopes := []string {oidc .ScopeOpenID , "profile" , "email" , oidc .ScopeOfflineAccess }
124130
131+ if noBrowser {
132+ token , err := runDeviceFlow (ctx , provider , providerURL , clientID , scopes )
133+ if err != nil {
134+ return err
135+ }
136+ return completeLogin (ctx , provider , clientID , authHostname , finalAPIHostname , scopes , token , verbose )
137+ }
138+
125139 listener , err := net .Listen ("tcp" , listenAddr )
126140 if err != nil {
127141 return fmt .Errorf ("failed to listen on %s: %w" , listenAddr , err )
@@ -262,6 +276,10 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
262276 // Wait for server shutdown *after* successful exchange (or failed exchange)
263277 <- serverClosed
264278
279+ return completeLogin (ctx , provider , clientID , authHostname , finalAPIHostname , scopes , token , verbose )
280+ }
281+
282+ func completeLogin (ctx context.Context , provider * oidc.Provider , clientID string , authHostname string , finalAPIHostname string , scopes []string , token * oauth2.Token , verbose bool ) error {
265283 // Verify ID token and extract claims
266284 idTokenString , ok := token .Extra ("id_token" ).(string )
267285 if ! ok {
@@ -362,6 +380,207 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
362380 return nil
363381}
364382
383+ type deviceAuthorizationResponse struct {
384+ DeviceCode string `json:"device_code"`
385+ UserCode string `json:"user_code"`
386+ VerificationURI string `json:"verification_uri"`
387+ VerificationURIComplete string `json:"verification_uri_complete"`
388+ ExpiresIn int64 `json:"expires_in"`
389+ Interval int64 `json:"interval"`
390+ }
391+
392+ type deviceTokenResponse struct {
393+ AccessToken string `json:"access_token"`
394+ TokenType string `json:"token_type"`
395+ RefreshToken string `json:"refresh_token"`
396+ IDToken string `json:"id_token"`
397+ ExpiresIn int64 `json:"expires_in"`
398+ Scope string `json:"scope"`
399+ }
400+
401+ type oauthErrorResponse struct {
402+ Error string `json:"error"`
403+ ErrorDescription string `json:"error_description"`
404+ }
405+
406+ func runDeviceFlow (ctx context.Context , provider * oidc.Provider , providerURL string , clientID string , scopes []string ) (* oauth2.Token , error ) {
407+ var discovery struct {
408+ DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
409+ }
410+ if err := provider .Claims (& discovery ); err != nil {
411+ return nil , fmt .Errorf ("failed to read OIDC discovery document: %w" , err )
412+ }
413+
414+ deviceEndpoint := discovery .DeviceAuthorizationEndpoint
415+ if deviceEndpoint == "" {
416+ deviceEndpoint = strings .TrimRight (providerURL , "/" ) + "/oauth/v2/device_authorization"
417+ }
418+
419+ deviceResp , err := requestDeviceAuthorization (ctx , deviceEndpoint , clientID , scopes )
420+ if err != nil {
421+ return nil , err
422+ }
423+
424+ fmt .Println ("\n To authenticate, visit:" )
425+ if deviceResp .VerificationURIComplete != "" {
426+ fmt .Printf ("\n %s\n \n " , deviceResp .VerificationURIComplete )
427+ } else {
428+ fmt .Printf ("\n %s\n \n " , deviceResp .VerificationURI )
429+ }
430+ if deviceResp .UserCode != "" {
431+ fmt .Printf ("And enter code: %s\n \n " , deviceResp .UserCode )
432+ }
433+
434+ fmt .Println ("Waiting for authorization..." )
435+
436+ tokenURL := provider .Endpoint ().TokenURL
437+ token , err := pollDeviceToken (ctx , tokenURL , clientID , deviceResp .DeviceCode , deviceResp .Interval , deviceResp .ExpiresIn )
438+ if err != nil {
439+ return nil , err
440+ }
441+
442+ return token , nil
443+ }
444+
445+ func requestDeviceAuthorization (ctx context.Context , endpoint string , clientID string , scopes []string ) (* deviceAuthorizationResponse , error ) {
446+ form := url.Values {}
447+ form .Set ("client_id" , clientID )
448+ form .Set ("scope" , strings .Join (scopes , " " ))
449+
450+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , endpoint , strings .NewReader (form .Encode ()))
451+ if err != nil {
452+ return nil , fmt .Errorf ("failed to create device authorization request: %w" , err )
453+ }
454+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
455+
456+ resp , err := http .DefaultClient .Do (req )
457+ if err != nil {
458+ return nil , fmt .Errorf ("device authorization request failed: %w" , err )
459+ }
460+ defer resp .Body .Close ()
461+
462+ body , err := io .ReadAll (resp .Body )
463+ if err != nil {
464+ return nil , fmt .Errorf ("failed to read device authorization response: %w" , err )
465+ }
466+
467+ if resp .StatusCode != http .StatusOK {
468+ var oauthErr oauthErrorResponse
469+ _ = json .Unmarshal (body , & oauthErr )
470+ if oauthErr .Error != "" {
471+ return nil , fmt .Errorf ("device authorization failed: %s (%s)" , oauthErr .Error , oauthErr .ErrorDescription )
472+ }
473+ return nil , fmt .Errorf ("device authorization failed with status %s" , resp .Status )
474+ }
475+
476+ var deviceResp deviceAuthorizationResponse
477+ if err := json .Unmarshal (body , & deviceResp ); err != nil {
478+ return nil , fmt .Errorf ("failed to parse device authorization response: %w" , err )
479+ }
480+ if deviceResp .DeviceCode == "" {
481+ return nil , fmt .Errorf ("device authorization response missing device_code" )
482+ }
483+
484+ return & deviceResp , nil
485+ }
486+
487+ func pollDeviceToken (ctx context.Context , tokenURL string , clientID string , deviceCode string , intervalSeconds int64 , expiresIn int64 ) (* oauth2.Token , error ) {
488+ interval := time .Duration (intervalSeconds ) * time .Second
489+ if interval <= 0 {
490+ interval = 5 * time .Second
491+ }
492+
493+ var deadline time.Time
494+ if expiresIn > 0 {
495+ deadline = time .Now ().Add (time .Duration (expiresIn ) * time .Second )
496+ }
497+
498+ for {
499+ if ! deadline .IsZero () && time .Now ().After (deadline ) {
500+ return nil , fmt .Errorf ("device authorization expired before completion" )
501+ }
502+
503+ token , errType , err := requestDeviceToken (ctx , tokenURL , clientID , deviceCode )
504+ if err != nil {
505+ return nil , err
506+ }
507+ switch errType {
508+ case "" :
509+ return token , nil
510+ case "authorization_pending" :
511+ // Keep polling
512+ case "slow_down" :
513+ interval += 5 * time .Second
514+ case "access_denied" :
515+ return nil , fmt .Errorf ("device authorization denied by user" )
516+ case "expired_token" :
517+ return nil , fmt .Errorf ("device authorization expired" )
518+ default :
519+ return nil , fmt .Errorf ("device authorization failed: %s" , errType )
520+ }
521+
522+ select {
523+ case <- ctx .Done ():
524+ return nil , ctx .Err ()
525+ case <- time .After (interval ):
526+ }
527+ }
528+ }
529+
530+ func requestDeviceToken (ctx context.Context , tokenURL string , clientID string , deviceCode string ) (* oauth2.Token , string , error ) {
531+ form := url.Values {}
532+ form .Set ("grant_type" , "urn:ietf:params:oauth:grant-type:device_code" )
533+ form .Set ("device_code" , deviceCode )
534+ form .Set ("client_id" , clientID )
535+
536+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , tokenURL , strings .NewReader (form .Encode ()))
537+ if err != nil {
538+ return nil , "" , fmt .Errorf ("failed to create device token request: %w" , err )
539+ }
540+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
541+
542+ resp , err := http .DefaultClient .Do (req )
543+ if err != nil {
544+ return nil , "" , fmt .Errorf ("device token request failed: %w" , err )
545+ }
546+ defer resp .Body .Close ()
547+
548+ body , err := io .ReadAll (resp .Body )
549+ if err != nil {
550+ return nil , "" , fmt .Errorf ("failed to read device token response: %w" , err )
551+ }
552+
553+ if resp .StatusCode != http .StatusOK {
554+ var oauthErr oauthErrorResponse
555+ _ = json .Unmarshal (body , & oauthErr )
556+ if oauthErr .Error != "" {
557+ return nil , oauthErr .Error , nil
558+ }
559+ return nil , "" , fmt .Errorf ("device token request failed with status %s" , resp .Status )
560+ }
561+
562+ var tokenResp deviceTokenResponse
563+ if err := json .Unmarshal (body , & tokenResp ); err != nil {
564+ return nil , "" , fmt .Errorf ("failed to parse device token response: %w" , err )
565+ }
566+
567+ token := & oauth2.Token {
568+ AccessToken : tokenResp .AccessToken ,
569+ TokenType : tokenResp .TokenType ,
570+ RefreshToken : tokenResp .RefreshToken ,
571+ ExpiresIn : tokenResp .ExpiresIn ,
572+ }
573+ if tokenResp .ExpiresIn > 0 {
574+ token .Expiry = time .Now ().Add (time .Duration (tokenResp .ExpiresIn ) * time .Second )
575+ }
576+ token = token .WithExtra (map [string ]any {
577+ "id_token" : tokenResp .IDToken ,
578+ "scope" : tokenResp .Scope ,
579+ })
580+
581+ return token , "" , nil
582+ }
583+
365584// addKnownUser adds a userKey (now email@hostname) to the known_users list in the keyring.
366585func addKnownUser (newUserKey string ) error {
367586 knownUsers := []string {}
0 commit comments