go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth/internal/login_session.go (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 //go:build !copybara 16 // +build !copybara 17 18 package internal 19 20 import ( 21 "context" 22 "crypto/rand" 23 "crypto/sha256" 24 "encoding/base64" 25 "fmt" 26 "net/http" 27 "os" 28 "path/filepath" 29 "strings" 30 "sync" 31 "time" 32 33 "golang.org/x/oauth2" 34 "golang.org/x/oauth2/google" 35 36 "go.chromium.org/luci/auth/loginsessionspb" 37 "go.chromium.org/luci/common/clock" 38 "go.chromium.org/luci/common/errors" 39 "go.chromium.org/luci/common/logging" 40 "go.chromium.org/luci/common/system/terminal" 41 "go.chromium.org/luci/grpc/prpc" 42 ) 43 44 var spinnerChars = []rune("⣾⣽⣻⢿⡿⣟⣯⣷") 45 46 type loginSessionTokenProvider struct { 47 loginSessionsHost string 48 clientID string 49 clientSecret string 50 scopes []string 51 transport http.RoundTripper 52 cacheKey CacheKey 53 } 54 55 func init() { 56 NewLoginSessionTokenProvider = func(ctx context.Context, loginSessionsHost, clientID, clientSecret string, scopes []string, transport http.RoundTripper) (TokenProvider, error) { 57 return &loginSessionTokenProvider{ 58 loginSessionsHost: loginSessionsHost, 59 clientID: clientID, 60 clientSecret: clientSecret, 61 scopes: scopes, 62 transport: transport, 63 // Reuse the same key as userAuthTokenProvider to share refresh tokens 64 // between two methods. They are compatible. 65 cacheKey: CacheKey{ 66 Key: fmt.Sprintf("user/%s", clientID), 67 Scopes: scopes, 68 }, 69 }, nil 70 } 71 } 72 73 func (p *loginSessionTokenProvider) RequiresInteraction() bool { 74 return true 75 } 76 77 func (p *loginSessionTokenProvider) Lightweight() bool { 78 return false 79 } 80 81 func (p *loginSessionTokenProvider) Email() string { 82 // We don't know the email before user logs in. 83 return UnknownEmail 84 } 85 86 func (p *loginSessionTokenProvider) CacheKey(ctx context.Context) (*CacheKey, error) { 87 return &p.cacheKey, nil 88 } 89 90 func (p *loginSessionTokenProvider) MintToken(ctx context.Context, base *Token) (*Token, error) { 91 // It is never correct to use this login method on bots. 92 if os.Getenv("SWARMING_HEADLESS") == "1" { 93 return nil, errors.Reason("interactive login flow is forbidden on bots").Err() 94 } 95 // Check if stdout is really a terminal a real user can interact with. 96 if !terminal.IsTerminal(int(os.Stdout.Fd())) { 97 return nil, errors.Reason("interactive login flow requires the stdout to be attached to a terminal").Err() 98 } 99 100 // The list of scopes is displayed on the consent page as well, but show it 101 // in the terminal too, for clarity. 102 fmt.Println("Getting a refresh token with following OAuth scopes:") 103 for _, scope := range p.scopes { 104 fmt.Printf(" * %s\n", scope) 105 } 106 fmt.Println() 107 108 // pRPC client to use for interacting with the sessions server. 109 httpClient := &http.Client{Transport: p.transport} 110 sessions := loginsessionspb.NewLoginSessionsClient(&prpc.Client{ 111 C: httpClient, 112 Host: p.loginSessionsHost, 113 }) 114 115 // Generate a code verifier (a random string) and corresponding challenge for 116 // PKCE protocol. They are used to make sure only us can exchange the 117 // authorization code for tokens (because only we know the code verifier). 118 codeVerifier := generateCodeVerifier() 119 codeChallenge := deriveCodeChallenge(codeVerifier) 120 121 // Collect some information about the running environment to show to the user 122 // so they can understand better what is invoking the login session. Both are 123 // best effort and optional (and easily spoofable). 124 executable, _ := os.Executable() 125 hostname, _ := os.Hostname() 126 127 // Start a new login session that we'll ask the user to complete. 128 session, err := sessions.CreateLoginSession(ctx, &loginsessionspb.CreateLoginSessionRequest{ 129 OauthClientId: p.clientID, 130 OauthScopes: p.scopes, 131 OauthS256CodeChallenge: codeChallenge, 132 ExecutableName: filepath.Base(executable), 133 ClientHostname: hostname, 134 }) 135 if err != nil { 136 return nil, errors.Annotate(err, "failed to create the login session").Err() 137 } 138 139 useFancyUI, doneUI := EnableVirtualTerminal() 140 if !useFancyUI { 141 // TODO(crbug/chromium/1411203): Support mode without virtual terminal. 142 logging.Warningf(ctx, "Virtual terminal is not enabled.") 143 } else { 144 defer doneUI() 145 } 146 147 fmt.Printf( 148 "Visit this link to complete the login flow in the browser. Do not share it with anyone!\n\n%s\n\n", 149 session.LoginFlowUrl, 150 ) 151 152 animationCtrl := startAnimation(ctx) 153 defer animationCtrl("", 0) 154 animationCtrl(session.ConfirmationCode, session.ConfirmationCodeRefresh.AsDuration()) 155 156 // Start polling the session until it moves to a finished state. 157 sessionID := session.Id 158 sessionPassword := session.Password 159 confirmationCode := session.ConfirmationCode 160 for session.State == loginsessionspb.LoginSession_PENDING { 161 // Use the poll interval suggested by the server, unless it is super small. 162 sleep := session.PollInterval.AsDuration() 163 if sleep < time.Second { 164 sleep = time.Second 165 } 166 if clock.Sleep(ctx, sleep).Incomplete() { 167 return nil, ctx.Err() 168 } 169 session, err = sessions.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{ 170 LoginSessionId: sessionID, 171 LoginSessionPassword: sessionPassword, 172 }) 173 if err != nil { 174 return nil, errors.Annotate(err, "failed to poll the login session").Err() 175 } 176 // Send new confirmation code to the loop that renders it. 177 if confirmationCode != session.ConfirmationCode { 178 confirmationCode = session.ConfirmationCode 179 animationCtrl(confirmationCode, session.ConfirmationCodeRefresh.AsDuration()) 180 } 181 } 182 animationCtrl("", 0) 183 184 // A session can expire or fail (if the user cancels it). 185 if session.State != loginsessionspb.LoginSession_SUCCEEDED { 186 fmt.Printf("Login session failed with status %s!\n", session.State) 187 if session.OauthError != "" { 188 fmt.Printf("OAuth error: %s\n", session.OauthError) 189 } 190 return nil, errors.Reason("the login flow failed").Err() 191 } 192 193 // We've got the authorization code and the redirect URL needed to complete 194 // the flow on our side. Note that we need to use codeVerifier here that only 195 // we know. 196 tok, err := (&oauth2.Config{ 197 ClientID: p.clientID, 198 ClientSecret: p.clientSecret, 199 RedirectURL: session.OauthRedirectUrl, 200 Endpoint: google.Endpoint, 201 }).Exchange(ctx, 202 session.OauthAuthorizationCode, 203 oauth2.SetAuthURLParam("code_verifier", codeVerifier), 204 ) 205 if err != nil { 206 return nil, err 207 } 208 return processProviderReply(ctx, tok, "") 209 } 210 211 func (p *loginSessionTokenProvider) RefreshToken(ctx context.Context, prev, base *Token) (*Token, error) { 212 return refreshToken(ctx, prev, base, &oauth2.Config{ 213 ClientID: p.clientID, 214 ClientSecret: p.clientSecret, 215 Endpoint: google.Endpoint, 216 }) 217 } 218 219 // generateCodeVerifier generates a random string used as a code_verifier in 220 // the PKCE protocol. 221 // 222 // See https://tools.ietf.org/html/rfc7636. 223 func generateCodeVerifier() string { 224 blob := make([]byte, 50) 225 if _, err := rand.Read(blob); err != nil { 226 panic(fmt.Sprintf("failed to generate code verifier: %s", err)) 227 } 228 return base64.RawURLEncoding.EncodeToString(blob) 229 } 230 231 // deriveCodeChallenge derives code_challenge from the code_verifier. 232 func deriveCodeChallenge(codeVerifier string) string { 233 codeVerifierS256 := sha256.Sum256([]byte(codeVerifier)) 234 return base64.RawURLEncoding.EncodeToString(codeVerifierS256[:]) 235 } 236 237 // codeAndExp represents a login confirmation code and its expiration time. 238 type codeAndExp struct { 239 code string // the code itself 240 exp time.Time // moment it expires 241 life time.Duration // its lifetime when we got it 242 } 243 244 // animator defines the functions that must be implemented to render 245 // the code to the terminal. 246 type animator interface { 247 // setup prepares the terminal and/or animator to display the confirmation code. 248 setup() 249 250 // updateCode updates the confirmation code being displayed by the animator. 251 updateCode(string) 252 253 // refreshAnimation updates any spinner animation using the remaining lifetime of 254 // current confirmation code. 255 refreshAnimation(context.Context, codeAndExp) 256 257 // finish is expected to print any information after the login has been completed. 258 finish() 259 } 260 261 // printTerminalCursorDown is a helper function to move the terminal cursor down. 262 func printTerminalCursorDown(lines int) { 263 fmt.Printf("\033[%dB", lines) 264 } 265 266 // printTerminalCursorUp is a helper function to move the terminal cursor up. 267 func printTerminalCursorUp(lines int) { 268 fmt.Printf("\033[%dA", lines) 269 } 270 271 // printTerminalLine resets the cursor to the start of the current line and prints 272 // a message, erasing anything previously displayed on the current line. 273 func printTerminalLine(msg string, args ...any) { 274 fmt.Printf("\r\033[2K"+msg+"\n", args...) 275 } 276 277 // dumbTerminal is a terminal that does not support some cursor control characters. 278 type dumbTerminal struct{} 279 280 func (dt *dumbTerminal) setup() {} 281 282 func (dt *dumbTerminal) updateCode(code string) { 283 fmt.Printf("%s\r", code) 284 } 285 286 // refreshAnimation does nothing for dumbTerminal, we do not animate. 287 func (dt *dumbTerminal) refreshAnimation(ctx context.Context, current codeAndExp) {} 288 289 func (dt *dumbTerminal) finish() { 290 fmt.Printf("Done!\n") 291 } 292 293 func dumbAnimator() *dumbTerminal { 294 return &dumbTerminal{} 295 } 296 297 // smartTerminal is a terminal that supports the cursor control characters needed to animate the code refresh. 298 type smartTerminal struct { 299 round int 300 } 301 302 func (st *smartTerminal) setup() { 303 // allocate lines, these will be overridden in smartAnimator. 304 fmt.Printf("\n\n\n\n") 305 } 306 307 // printCode uses control characters with a smart terminal to move the terminal cursor up and down, 308 // overwriting previous code. 309 func (st *smartTerminal) updateCode(code string) { 310 printTerminalCursorUp(4) 311 printTerminalLine("%s", code) 312 printTerminalCursorDown(3) 313 } 314 315 // refreshAnimation re-renders the loading bar animation and spinner animation as the 316 // code expiry goes down. 317 func (st *smartTerminal) refreshAnimation(ctx context.Context, current codeAndExp) { 318 spinner := string(spinnerChars[st.round%len(spinnerChars)]) 319 st.round += 1 320 321 // Calculate a portion of code's lifetime left. 322 ratio := float32(time.Until(current.exp).Seconds() / current.life.Seconds()) 323 324 // Convert it into a number of progress bar characters to print. 325 total := len(current.code) 326 filled := int(ratio*float32(total)) + 1 327 if filled < 0 { 328 filled = 0 329 } else if filled > total { 330 filled = total 331 } 332 333 // Redraw everything but code. 334 printTerminalCursorUp(3) 335 printTerminalLine("%s%s", strings.Repeat("─", filled), strings.Repeat(" ", total-filled)) 336 printTerminalLine("") 337 printTerminalLine("Waiting for the login flow to complete in the browser %s", spinner) 338 } 339 340 func (st *smartTerminal) finish() { 341 // Redraw the last line replacing the spinner with "Done". 342 printTerminalCursorUp(1) 343 printTerminalLine("Waiting for the login flow to complete in the browser. Done!\n") 344 } 345 346 func smartAnimator() *smartTerminal { 347 return &smartTerminal{ 348 round: 0, 349 } 350 } 351 352 // startAnimation starts background rendering of the most recent confirmation 353 // code (plus some cute spinner animation). 354 // 355 // Returns a function that can be used to control the animation. Passing it 356 // a non-empty string would replace the confirmation code. Passing it an empty 357 // string would stop the animation. 358 func startAnimation(ctx context.Context) (ctrl func(string, time.Duration)) { 359 spinCh := make(chan codeAndExp) 360 done := false 361 362 spinWG := sync.WaitGroup{} 363 spinWG.Add(1) 364 365 fmt.Printf("When asked, use this confirmation code (it refreshes with time):\n\n") 366 var a animator 367 a = dumbAnimator() 368 if !IsDumbTerminal() { 369 a = smartAnimator() 370 } 371 372 prevCode := "" 373 374 go func() { 375 defer spinWG.Done() 376 current := codeAndExp{} 377 a.setup() 378 loop: 379 for { 380 select { 381 case code, ok := <-spinCh: 382 if !ok { 383 break loop 384 } 385 current = code 386 if current.code != prevCode { 387 a.updateCode(current.code) 388 prevCode = current.code 389 } 390 case res := <-clock.After(ctx, 100*time.Millisecond): 391 if res.Err != nil { 392 break loop 393 } 394 } 395 396 // Wait until we get the first confirmation code before rendering 397 // anything. This should be fast, since we already know it by the time 398 // the goroutine starts, so we just wait for local goroutines to 399 // "synchronize". 400 if current.code == "" { 401 continue 402 } 403 a.refreshAnimation(ctx, current) 404 } 405 a.finish() 406 }() 407 return func(code string, exp time.Duration) { 408 if !done { 409 if code == "" { 410 done = true 411 close(spinCh) 412 spinWG.Wait() 413 } else { 414 spinCh <- codeAndExp{code, time.Now().Add(exp), exp} 415 } 416 } 417 } 418 }