@@ -33,10 +33,55 @@ import (
3333 jwt "github.com/golang-jwt/jwt/v5"
3434 "github.com/spf13/cobra"
3535 "github.com/spf13/viper"
36+ "golang.org/x/oauth2"
3637)
3738
3839type app struct {
3940 serverChan chan error
41+ callback * loginCallback
42+ }
43+
44+ // loginCallback turns the callback of a browser login into the session token
45+ type loginCallback struct {
46+ callbackURL string
47+ // codeVerifier is set when the login uses the code exchange
48+ codeVerifier string
49+ // whether the control plane still allows the token in the callback URL
50+ legacyAllowed bool
51+ exchange func (ctx context.Context , handoff , codeVerifier , callbackURL string ) (string , error )
52+ warn func (msg string )
53+ }
54+
55+ var errNoCallbackCredentials = errors .New ("the login callback did not carry a login code" )
56+
57+ // token returns the session token for the callback request
58+ func (c * loginCallback ) token (r * http.Request ) (string , error ) {
59+ q := r .URL .Query ()
60+ if handoff := q .Get (oauth .QueryParamHandoff ); handoff != "" && c .codeVerifier != "" {
61+ token , err := c .exchange (r .Context (), handoff , c .codeVerifier , c .callbackURL )
62+ if err != nil {
63+ return "" , fmt .Errorf ("exchanging the login code: %w" , err )
64+ }
65+ return token , nil
66+ }
67+
68+ token := q .Get (oauth .QueryParamLegacyToken )
69+ if token == "" {
70+ return "" , errNoCallbackCredentials
71+ }
72+
73+ // An older control plane, the token in the callback URL is the only method it has
74+ if c .codeVerifier == "" {
75+ return token , nil
76+ }
77+
78+ // The control plane supports the exchange, but the login page did not forward the code challenge
79+ if ! c .legacyAllowed {
80+ return "" , errors .New ("the login returned the session token in the callback URL, which the control plane does not allow, refusing it" )
81+ }
82+
83+ c .warn ("the login returned the session token in the callback URL instead of a login code. This method is deprecated, make sure the login page forwards the code challenge to the control plane" )
84+ return token , nil
4085}
4186
4287func newAuthLoginCmd () * cobra.Command {
@@ -61,12 +106,12 @@ func interactiveAuth(forceHeadless bool) error {
61106 return err
62107 }
63108 // Get the auth login from the control plane directly
64- authURL , err := retrieveLoginURL ()
109+ info , err := retrieveLoginInfo ()
65110 if err != nil {
66111 return err
67112 }
68113
69- serverLoginURL , err := url .Parse (authURL )
114+ serverLoginURL , err := url .Parse (info . GetLoginUrl () )
70115 if err != nil {
71116 return err
72117 }
@@ -80,6 +125,18 @@ func interactiveAuth(forceHeadless bool) error {
80125 return headlessAuth (serverLoginURL )
81126 }
82127
128+ a .callback = & loginCallback {
129+ callbackURL : callbackURL .String (),
130+ legacyAllowed : info .GetLoginLegacyCallbackAllowed (),
131+ exchange : exchangeHandoff ,
132+ warn : func (msg string ) { logger .Warn ().Msg (msg ) },
133+ }
134+
135+ // Ask for a login code instead of the token when the control plane supports it
136+ if info .GetLoginCodeExchangeSupported () {
137+ a .callback .codeVerifier = addCodeChallenge (serverLoginURL )
138+ }
139+
83140 err = openbrowser (serverLoginURL .String ())
84141 if err != nil {
85142 logger .Debug ().Err (err ).Msg ("falling back to manual login" )
@@ -109,11 +166,44 @@ func interactiveAuth(forceHeadless bool) error {
109166 return nil
110167}
111168
112- func headlessAuth (loginURL * url.URL ) error {
113- // Remove cli-callback query parameter to indicate the server to show it inline
169+ // addCodeChallenge adds a S256 code challenge to the login URL and returns its verifier
170+ func addCodeChallenge (loginURL * url.URL ) string {
171+ verifier := oauth2 .GenerateVerifier ()
172+
114173 q := loginURL .Query ()
115- q .Set ("callback" , "" )
174+ q .Set (oauth .QueryParamCodeChallenge , oauth2 .S256ChallengeFromVerifier (verifier ))
175+ q .Set (oauth .QueryParamCodeChallengeMethod , oauth .CodeChallengeMethodS256 )
116176 loginURL .RawQuery = q .Encode ()
177+
178+ return verifier
179+ }
180+
181+ // exchangeHandoff trades the login code for the session token
182+ func exchangeHandoff (ctx context.Context , handoff , codeVerifier , callbackURL string ) (string , error ) {
183+ resp , err := pb .NewAuthServiceClient (ActionOpts .CPConnection ).ExchangeHandoff (ctx , & pb.AuthServiceExchangeHandoffRequest {
184+ Handoff : handoff ,
185+ CodeVerifier : codeVerifier ,
186+ Callback : callbackURL ,
187+ })
188+ if err != nil {
189+ return "" , err
190+ }
191+
192+ return resp .GetToken (), nil
193+ }
194+
195+ // toHeadlessLoginURL removes the callback, so the server shows the token inline. The code
196+ // challenge goes too: the headless page has no code to exchange, and a challenge needs a callback
197+ func toHeadlessLoginURL (loginURL * url.URL ) {
198+ q := loginURL .Query ()
199+ q .Set (oauth .QueryParamCallback , "" )
200+ q .Del (oauth .QueryParamCodeChallenge )
201+ q .Del (oauth .QueryParamCodeChallengeMethod )
202+ loginURL .RawQuery = q .Encode ()
203+ }
204+
205+ func headlessAuth (loginURL * url.URL ) error {
206+ toHeadlessLoginURL (loginURL )
117207 fmt .Printf ("To authenticate, click on the following link and paste the result back here\n \n %s\n \n " , loginURL .String ())
118208
119209 fmt .Print ("Enter Token: " )
@@ -145,18 +235,19 @@ func saveAuthToken(token string) error {
145235}
146236
147237func (a * app ) handleCallback (w http.ResponseWriter , r * http.Request ) {
148- token := r .URL .Query ().Get ("t" )
149-
238+ var err error
150239 defer func () {
151- a .serverChan <- nil
240+ a .serverChan <- err
152241 }()
153242
154- if token == "" {
243+ token , err := a .callback .token (r )
244+ if err != nil {
155245 w .WriteHeader (http .StatusExpectationFailed )
246+ fmt .Fprintln (w , "login failed, please check the terminal" )
156247 return
157248 }
158249
159- if err : = saveAuthToken (token ); err != nil {
250+ if err = saveAuthToken (token ); err != nil {
160251 w .WriteHeader (http .StatusInternalServerError )
161252 return
162253 }
@@ -197,14 +288,10 @@ func isRemoteSession() bool {
197288 return false
198289}
199290
200- // Retrieve loginURL from the control plane
201- func retrieveLoginURL () (string , error ) {
291+ // Retrieve the login URL and the login capabilities from the control plane
292+ func retrieveLoginInfo () (* pb. InfozResponse , error ) {
202293 client := pb .NewStatusServiceClient (ActionOpts .CPConnection )
203- resp , err := client .Infoz (context .Background (), & pb.InfozRequest {})
204- if err != nil {
205- return "" , err
206- }
207- return resp .LoginUrl , nil
294+ return client .Infoz (context .Background (), & pb.InfozRequest {})
208295}
209296
210297// Create a local HTTP listener with a random available port
0 commit comments