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  }