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  }