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 }