go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/auth/integration/gsutil/gsutil.go (about) 1 // Copyright 2017 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 gsutil implements a hacky shim that makes gsutil use LUCI local auth. 16 // 17 // It constructs a special .boto config file that instructs gsutil to use local 18 // HTTP endpoint as token_uri (it's the one that exchanges OAuth2 refresh token 19 // for an access token). This endpoint is implemented on top of LUCI auth. 20 // 21 // Thus gsutil thinks it's using 3-legged OAuth2 flow, while in fact it is 22 // getting the token through LUCI protocols. 23 package gsutil 24 25 import ( 26 "context" 27 "crypto/subtle" 28 "encoding/base64" 29 "encoding/json" 30 "fmt" 31 "net" 32 "net/http" 33 "sync" 34 "time" 35 36 "golang.org/x/oauth2" 37 38 "go.chromium.org/luci/common/clock" 39 "go.chromium.org/luci/common/data/rand/cryptorand" 40 "go.chromium.org/luci/common/errors" 41 "go.chromium.org/luci/common/logging" 42 "go.chromium.org/luci/common/retry/transient" 43 "go.chromium.org/luci/common/runtime/paniccatcher" 44 45 "go.chromium.org/luci/auth/integration/internal/localsrv" 46 ) 47 48 // Server runs a local server that handles requests to token_uri. 49 // 50 // It also manages a directory with gsutil state, since part of the state is 51 // the cached OAuth2 token that we don't want to put into default global 52 // ~/.gsutil state directory. 53 type Server struct { 54 // Source is used to obtain OAuth2 tokens. 55 Source oauth2.TokenSource 56 // StateDir is where to drop new .boto file and where to keep gsutil state. 57 StateDir string 58 // Port is a local TCP port to bind to or 0 to allow the OS to pick one. 59 Port int 60 61 srv localsrv.Server 62 } 63 64 // Start launches background goroutine with the serving loop and prepares .boto. 65 // 66 // Returns absolute path to new .boto file. It is always inside StateDir. Caller 67 // is responsible for creating StateDir (and later deleting it, if necessary). 68 // 69 // The provided context is used as base context for request handlers and for 70 // logging. The server must be eventually stopped with Stop(). 71 func (s *Server) Start(ctx context.Context) (botoCfg string, err error) { 72 // The secret will be used as fake refresh token, to verify clients of the 73 // protocol have read access to .boto file (where this secret is stored). 74 blob := make([]byte, 48) 75 if _, err := cryptorand.Read(ctx, blob); err != nil { 76 return "", errors.Annotate(err, "failed to read random bytes").Err() 77 } 78 secret := base64.RawStdEncoding.EncodeToString(blob) 79 80 // Launch the server to get the port number. 81 addr, err := s.srv.Start(ctx, "gsutil-auth", s.Port, func(c context.Context, l net.Listener, wg *sync.WaitGroup) error { 82 return s.serve(c, l, wg, secret) 83 }) 84 if err != nil { 85 return "", errors.Annotate(err, "failed to start the server").Err() 86 } 87 defer func() { 88 if err != nil { 89 s.srv.Stop(ctx) 90 } 91 }() 92 93 // Prepare a state directory for gsutil (otherwise it uses '~/.gsutil'), drop 94 // .boto file there pointing to this directory and to our server. 95 return PrepareStateDir(&Boto{ 96 StateDir: s.StateDir, 97 RefreshToken: secret, 98 ProviderLabel: "LUCI Local", 99 ProviderAuthURI: fmt.Sprintf("http://%s/gsutil/authorization", addr), 100 ProviderTokenURI: fmt.Sprintf("http://%s/gsutil/token", addr), 101 }) 102 } 103 104 // Stop closes the listening socket, notifies pending requests to abort and 105 // stops the internal serving goroutine. 106 // 107 // Safe to call multiple times. Once stopped, the server cannot be started again 108 // (make a new instance of Server instead). 109 // 110 // Uses the given context for the deadline when waiting for the serving loop 111 // to stop. 112 func (s *Server) Stop(ctx context.Context) error { 113 return s.srv.Stop(ctx) 114 } 115 116 //////////////////////////////////////////////////////////////////////////////// 117 118 // serve runs the serving loop. 119 func (s *Server) serve(ctx context.Context, l net.Listener, wg *sync.WaitGroup, secret string) error { 120 mux := http.NewServeMux() 121 122 mux.Handle("/gsutil/authorization", &handler{ctx, wg, func(rw http.ResponseWriter, r *http.Request) { 123 // Authorization URI is normally used during interactive login to eventually 124 // generate a refresh token. Since we pass the refresh token in .boto 125 // already, it must never be called. 126 rw.WriteHeader(http.StatusNotImplemented) 127 }}) 128 129 mux.Handle("/gsutil/token", &handler{ctx, wg, func(rw http.ResponseWriter, r *http.Request) { 130 err := s.handleTokenRequest(rw, r, secret) 131 132 code := 0 133 msg := "" 134 if transient.Tag.In(err) { 135 code = http.StatusInternalServerError 136 msg = fmt.Sprintf("Transient error - %s", err) 137 } else if err != nil { 138 code = http.StatusBadRequest 139 msg = fmt.Sprintf("Bad request - %s", err) 140 } 141 142 if code != 0 { 143 logging.Errorf(ctx, "%s", msg) 144 http.Error(rw, msg, code) 145 } 146 }}) 147 148 srv := http.Server{Handler: mux} 149 return srv.Serve(l) 150 } 151 152 // handleTokenRequest handles /token call. 153 // 154 // The body of the request is documented here (among many other places): 155 // 156 // https://developers.google.com/identity/protocols/OAuth2InstalledApp#offline 157 // 158 // We ignore client_id and client_secret, since we aren't really running OAuth2. 159 func (s *Server) handleTokenRequest(rw http.ResponseWriter, r *http.Request, secret string) error { 160 ctx := r.Context() 161 162 // We support only refreshing access token via 'refresh_token' grant. 163 if r.PostFormValue("grant_type") != "refresh_token" { 164 return fmt.Errorf("expecting 'refresh_token' grant type") 165 } 166 167 // The token must match whatever we passed to gsutil via .boto. Unfortunately, 168 // gcloud's gsutil wrapper overrides the refresh token in the config unless 169 // 'pass_credentials_to_gsutil' is set to false via 170 // 171 // $ gcloud config set pass_credentials_to_gsutil false 172 // 173 // So hint the user to set it. 174 passedToken := r.PostFormValue("refresh_token") 175 if subtle.ConstantTimeCompare([]byte(passedToken), []byte(secret)) != 1 { 176 return fmt.Errorf("wrong refresh_token (if running via gcloud, set pass_credentials_to_gsutil = false)") 177 } 178 179 // Good enough. Grab an access token through the source and return it. 180 tok, err := s.Source.Token() 181 if err != nil { 182 return err 183 } 184 rw.Header().Set("Content-Type", "application/json") 185 return json.NewEncoder(rw).Encode(map[string]any{ 186 "access_token": tok.AccessToken, 187 "expires_in": clock.Until(ctx, tok.Expiry) / time.Second, 188 "token_type": "Bearer", 189 }) 190 } 191 192 // handler implements http.Handler by wrapping the given handler with some 193 // housekeeping stuff. 194 type handler struct { 195 ctx context.Context 196 wg *sync.WaitGroup 197 handler http.HandlerFunc 198 } 199 200 func (h *handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 201 h.wg.Add(1) 202 defer h.wg.Done() 203 204 defer paniccatcher.Catch(func(p *paniccatcher.Panic) { 205 logging.Fields{ 206 "panic.error": p.Reason, 207 }.Errorf(h.ctx, "Caught panic during handling of %q: %s\n%s", r.RequestURI, p.Reason, p.Stack) 208 http.Error(rw, "Internal Server Error. See logs.", http.StatusInternalServerError) 209 }) 210 211 logging.Debugf(h.ctx, "Handling %s %s", r.Method, r.RequestURI) 212 h.handler(rw, r.WithContext(h.ctx)) 213 }