go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth/internal/user.go (about)

     1  // Copyright 2015 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  package internal
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"time"
    21  
    22  	"golang.org/x/oauth2"
    23  	"golang.org/x/oauth2/google"
    24  
    25  	"go.chromium.org/luci/common/gcloud/googleoauth"
    26  	"go.chromium.org/luci/common/logging"
    27  	"go.chromium.org/luci/common/retry/transient"
    28  )
    29  
    30  type userAuthTokenProvider struct {
    31  	config   *oauth2.Config
    32  	cacheKey CacheKey
    33  }
    34  
    35  // NewUserAuthTokenProvider returns TokenProvider that can perform 3-legged
    36  // OAuth flow involving interaction with a user.
    37  func NewUserAuthTokenProvider(ctx context.Context, clientID, clientSecret string, scopes []string) (TokenProvider, error) {
    38  	return &userAuthTokenProvider{
    39  		config: &oauth2.Config{
    40  			ClientID:     clientID,
    41  			ClientSecret: clientSecret,
    42  			Endpoint:     google.Endpoint,
    43  			RedirectURL:  "urn:ietf:wg:oauth:2.0:oob",
    44  			Scopes:       scopes,
    45  		},
    46  		cacheKey: CacheKey{
    47  			Key:    fmt.Sprintf("user/%s", clientID),
    48  			Scopes: scopes,
    49  		},
    50  	}, nil
    51  }
    52  
    53  func (p *userAuthTokenProvider) RequiresInteraction() bool {
    54  	return true
    55  }
    56  
    57  func (p *userAuthTokenProvider) Lightweight() bool {
    58  	return false
    59  }
    60  
    61  func (p *userAuthTokenProvider) Email() string {
    62  	// We don't know the email before user logs in.
    63  	return UnknownEmail
    64  }
    65  
    66  func (p *userAuthTokenProvider) CacheKey(ctx context.Context) (*CacheKey, error) {
    67  	return &p.cacheKey, nil
    68  }
    69  
    70  func (p *userAuthTokenProvider) MintToken(ctx context.Context, base *Token) (*Token, error) {
    71  	// The list of scopes is displayed on the consent page as well, but show it
    72  	// in the terminal too, for clarity.
    73  	fmt.Println("Getting a refresh token with following OAuth scopes:")
    74  	for _, scope := range p.config.Scopes {
    75  		fmt.Printf("  * %s\n", scope)
    76  	}
    77  	fmt.Println()
    78  
    79  	// Grab the authorization code by redirecting a user to a consent screen.
    80  	url := p.config.AuthCodeURL("", oauth2.AccessTypeOffline, oauth2.ApprovalForce)
    81  	fmt.Printf("Visit the following URL to get the authorization code and copy-paste it below.\n\n%s\n\n", url)
    82  	fmt.Printf("Authorization code: ")
    83  	var code string
    84  	if _, err := fmt.Scan(&code); err != nil {
    85  		return nil, err
    86  	}
    87  	fmt.Println()
    88  
    89  	// Exchange it for an access and (possibly) ID tokens.
    90  	tok, err := p.config.Exchange(ctx, code)
    91  	if err != nil {
    92  		return nil, err
    93  	}
    94  	return processProviderReply(ctx, tok, "")
    95  }
    96  
    97  func (p *userAuthTokenProvider) RefreshToken(ctx context.Context, prev, base *Token) (*Token, error) {
    98  	return refreshToken(ctx, prev, base, p.config)
    99  }
   100  
   101  func refreshToken(ctx context.Context, prev, base *Token, cfg *oauth2.Config) (*Token, error) {
   102  	// Clear expiration time to force token refresh. Do not use 0 since it means
   103  	// that token never expires.
   104  	t := prev.Token
   105  	t.Expiry = time.Unix(1, 0)
   106  	switch newTok, err := grabToken(cfg.TokenSource(ctx, &t)); {
   107  	case err == nil:
   108  		return processProviderReply(ctx, newTok, prev.Email)
   109  	case transient.Tag.In(err):
   110  		logging.Warningf(ctx, "Transient error when refreshing the token - %s", err)
   111  		return nil, err
   112  	default:
   113  		logging.Warningf(ctx, "Bad refresh token - %s", err)
   114  		return nil, ErrBadRefreshToken
   115  	}
   116  }
   117  
   118  // processProviderReply transforms oauth2.Token into Token by extracting some
   119  // useful information from it.
   120  //
   121  // May make an RPC to the token info endpoint.
   122  func processProviderReply(ctx context.Context, tok *oauth2.Token, email string) (*Token, error) {
   123  	// If have the ID token, parse its payload to see the expiry and the email.
   124  	// Note that we don't verify the signature. We just got the token from the
   125  	// provider we trust.
   126  	var claims *IDTokenClaims
   127  	var idToken string
   128  	var err error
   129  	if idToken, _ = tok.Extra("id_token").(string); idToken != "" {
   130  		if claims, err = ParseIDTokenClaims(idToken); err != nil {
   131  			return nil, err
   132  		}
   133  	} else {
   134  		idToken = NoIDToken
   135  	}
   136  
   137  	// ID token has the freshest email.
   138  	if claims != nil && claims.EmailVerified && claims.Email != "" {
   139  		email = claims.Email
   140  	} else if email == "" {
   141  		// If we still don't know the email associated with the credentials, make
   142  		// an RPC to the token info endpoint to get it.
   143  		if email, err = grabEmail(ctx, tok); err != nil {
   144  			return nil, err
   145  		}
   146  	}
   147  
   148  	// We rely on `tok` expiry to know when to refresh both the access and ID
   149  	// tokens. Usually they have roughly the same expiry. Check this.
   150  	if claims != nil {
   151  		idTokenExpiry := time.Unix(claims.Exp, 0)
   152  		delta := idTokenExpiry.Sub(tok.Expiry)
   153  		if delta < 0 {
   154  			delta = -delta
   155  		}
   156  		if delta > time.Minute {
   157  			logging.Warningf(ctx, "The ID token and access tokens have unexpectedly large discrepancy in expiration times: %v", delta)
   158  		}
   159  		if idTokenExpiry.Before(tok.Expiry) {
   160  			tok.Expiry = idTokenExpiry
   161  		}
   162  	}
   163  
   164  	return &Token{
   165  		Token:   *tok,
   166  		IDToken: idToken,
   167  		Email:   email,
   168  	}, nil
   169  }
   170  
   171  // grabEmail fetches an email associated with the given token.
   172  //
   173  // May return (NoEmail, nil) if the token can't be resolved into an email.
   174  func grabEmail(ctx context.Context, tok *oauth2.Token) (string, error) {
   175  	info, err := googleoauth.GetTokenInfo(ctx, googleoauth.TokenInfoParams{
   176  		AccessToken: tok.AccessToken,
   177  	})
   178  	if err != nil {
   179  		return "", err
   180  	}
   181  	if info.Email == "" {
   182  		return NoEmail, nil
   183  	}
   184  	return info.Email, nil
   185  }