github.com/google/osv-scalibr@v0.4.1/clients/datasource/http_auth.go (about)

     1  // Copyright 2025 Google LLC
     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 datasource
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"crypto/md5"
    21  	"crypto/rand"
    22  	"encoding/base64"
    23  	"encoding/hex"
    24  	"fmt"
    25  	"net/http"
    26  	"slices"
    27  	"strings"
    28  	"sync/atomic"
    29  )
    30  
    31  // HTTPAuthMethod definesthe type of HTTP authentication method.
    32  type HTTPAuthMethod int
    33  
    34  // HTTP authentication method.
    35  const (
    36  	AuthBasic HTTPAuthMethod = iota
    37  	AuthBearer
    38  	AuthDigest
    39  )
    40  
    41  // HTTPAuthentication holds the information needed for general HTTP Authentication support.
    42  // Requests made through this will automatically populate the relevant info in the Authorization headers.
    43  // This is a general implementation and should be suitable for use with any ecosystem.
    44  type HTTPAuthentication struct {
    45  	SupportedMethods []HTTPAuthMethod // In order of preference, only one method will be attempted.
    46  
    47  	// AlwaysAuth determines whether to always send auth headers.
    48  	// If false, the server must respond with a WWW-Authenticate header which will be checked for supported methods.
    49  	// Must be set to false to use Digest authentication.
    50  	AlwaysAuth bool
    51  
    52  	// Shared
    53  	Username string // Basic & Digest, plain text.
    54  	Password string // Basic & Digest, plain text.
    55  	// Basic
    56  	BasicAuth string // Base64-encoded username:password. Overrides Username & Password fields if set.
    57  	// Bearer
    58  	BearerToken string
    59  	// Digest
    60  	CnonceFunc func() string // Function used to generate cnonce string for Digest. OK to leave unassigned. Mostly for use in tests.
    61  
    62  	lastUsed atomic.Value // The last-used authentication method - used when AlwaysAuth is false to automatically send Basic auth.
    63  }
    64  
    65  // Get makes an http GET request with the given http.Client.
    66  // The Authorization Header will automatically be populated according from the fields in the HTTPAuthentication.
    67  func (auth *HTTPAuthentication) Get(ctx context.Context, httpClient *http.Client, url string) (*http.Response, error) {
    68  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    69  	if err != nil {
    70  		return nil, err
    71  	}
    72  
    73  	// For convenience, have the nil HTTPAuthentication just make an unauthenticated request.
    74  	if auth == nil {
    75  		return httpClient.Do(req)
    76  	}
    77  
    78  	if auth.AlwaysAuth {
    79  		for _, method := range auth.SupportedMethods {
    80  			ok := false
    81  			switch method {
    82  			case AuthBasic:
    83  				ok = auth.addBasic(req)
    84  			case AuthBearer:
    85  				ok = auth.addBearer(req)
    86  			case AuthDigest:
    87  				// AuthDigest needs a challenge from WWW-Authenticate, so we cannot always add the auth.
    88  			}
    89  			if ok {
    90  				break
    91  			}
    92  		}
    93  
    94  		return httpClient.Do(req)
    95  	}
    96  
    97  	// If the last request we made to this server used Basic or Bearer auth, send the header with this request
    98  	if lastUsed, ok := auth.lastUsed.Load().(HTTPAuthMethod); ok {
    99  		switch lastUsed {
   100  		case AuthBasic:
   101  			auth.addBasic(req)
   102  		case AuthBearer:
   103  			auth.addBearer(req)
   104  		case AuthDigest:
   105  			// Cannot add AuthDigest without the challenge from the initial request.
   106  		}
   107  	}
   108  
   109  	resp, err := httpClient.Do(req)
   110  	if err != nil {
   111  		return nil, err
   112  	}
   113  	if resp.StatusCode != http.StatusUnauthorized {
   114  		return resp, nil
   115  	}
   116  
   117  	wwwAuth := resp.Header.Values("WWW-Authenticate")
   118  
   119  	ok := false
   120  	var usedMethod HTTPAuthMethod
   121  	req, err = http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   122  	if err != nil {
   123  		return nil, err
   124  	}
   125  	for _, method := range auth.SupportedMethods {
   126  		switch method {
   127  		case AuthBasic:
   128  			if auth.authIndex(wwwAuth, "Basic") >= 0 {
   129  				ok = auth.addBasic(req)
   130  			}
   131  		case AuthBearer:
   132  			if auth.authIndex(wwwAuth, "Bearer") >= 0 {
   133  				ok = auth.addBearer(req)
   134  			}
   135  		case AuthDigest:
   136  			if idx := auth.authIndex(wwwAuth, "Digest"); idx >= 0 {
   137  				ok = auth.addDigest(req, wwwAuth[idx])
   138  			}
   139  		}
   140  		if ok {
   141  			usedMethod = method
   142  			break
   143  		}
   144  	}
   145  
   146  	if ok {
   147  		defer resp.Body.Close() // Close the original request before we discard it.
   148  		resp, err = httpClient.Do(req)
   149  	}
   150  	if resp.StatusCode == http.StatusOK {
   151  		auth.lastUsed.Store(usedMethod)
   152  	}
   153  	// The original request's response will be returned if there is no matching methods.
   154  	return resp, err
   155  }
   156  
   157  func (auth *HTTPAuthentication) authIndex(wwwAuth []string, authScheme string) int {
   158  	return slices.IndexFunc(wwwAuth, func(s string) bool {
   159  		scheme, _, _ := strings.Cut(s, " ")
   160  		return scheme == authScheme
   161  	})
   162  }
   163  
   164  func (auth *HTTPAuthentication) addBasic(req *http.Request) bool {
   165  	if auth.BasicAuth != "" {
   166  		req.Header.Set("Authorization", "Basic "+auth.BasicAuth)
   167  
   168  		return true
   169  	}
   170  
   171  	if auth.Username != "" && auth.Password != "" {
   172  		authStr := base64.StdEncoding.EncodeToString([]byte(auth.Username + ":" + auth.Password))
   173  		req.Header.Set("Authorization", "Basic "+authStr)
   174  
   175  		return true
   176  	}
   177  
   178  	return false
   179  }
   180  
   181  func (auth *HTTPAuthentication) addBearer(req *http.Request) bool {
   182  	if auth.BearerToken != "" {
   183  		req.Header.Set("Authorization", "Bearer "+auth.BearerToken)
   184  
   185  		return true
   186  	}
   187  
   188  	return false
   189  }
   190  
   191  func (auth *HTTPAuthentication) addDigest(req *http.Request, challenge string) bool {
   192  	// Mostly following the algorithm as outlined in https://en.wikipedia.org/wiki/Digest_access_authentication
   193  	// And also https://datatracker.ietf.org/doc/html/rfc2617
   194  	if auth.Username == "" || auth.Password == "" {
   195  		return false
   196  	}
   197  	params := auth.parseChallenge(challenge)
   198  	realm, ok := params["realm"]
   199  	if !ok {
   200  		return false
   201  	}
   202  
   203  	nonce, ok := params["nonce"]
   204  	if !ok {
   205  		return false
   206  	}
   207  	var cnonce string
   208  	//nolint:gosec
   209  	ha1 := md5.Sum([]byte(auth.Username + ":" + realm + ":" + auth.Password))
   210  	switch params["algorithm"] {
   211  	case "MD5-sess":
   212  		cnonce = auth.cnonce()
   213  		if cnonce == "" {
   214  			return false
   215  		}
   216  		var b bytes.Buffer
   217  		_, _ = fmt.Fprintf(&b, "%x:%s:%s", ha1, nonce, cnonce)
   218  		//nolint:gosec
   219  		ha1 = md5.Sum(b.Bytes())
   220  	case "MD5":
   221  	case "":
   222  	default:
   223  		return false
   224  	}
   225  
   226  	// Only support "auth" qop
   227  	if qop, ok := params["qop"]; ok && !slices.Contains(strings.Split(qop, ","), "auth") {
   228  		return false
   229  	}
   230  
   231  	uri := req.URL.Path
   232  
   233  	//nolint:gosec
   234  	ha2 := md5.Sum([]byte(req.Method + ":" + uri))
   235  
   236  	// hard-coding nonceCount to 1 since we don't make a request more than once
   237  	nonceCount := "00000001"
   238  
   239  	var b bytes.Buffer
   240  	if _, ok := params["qop"]; ok {
   241  		if cnonce == "" {
   242  			cnonce = auth.cnonce()
   243  			if cnonce == "" {
   244  				return false
   245  			}
   246  		}
   247  		_, _ = fmt.Fprintf(&b, "%x:%s:%s:%s:%s:%x", ha1, nonce, nonceCount, cnonce, "auth", ha2)
   248  	} else {
   249  		_, _ = fmt.Fprintf(&b, "%x:%s:%x", ha1, nonce, ha2)
   250  	}
   251  	//nolint:gosec
   252  	response := md5.Sum(b.Bytes())
   253  
   254  	var sb strings.Builder
   255  	_, _ = fmt.Fprintf(&sb, "Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\"",
   256  		auth.Username, realm, nonce, uri)
   257  	if _, ok := params["qop"]; ok {
   258  		_, _ = fmt.Fprintf(&sb, ", qop=auth, nc=%s, cnonce=\"%s\"", nonceCount, cnonce)
   259  	}
   260  	if alg, ok := params["algorithm"]; ok {
   261  		_, _ = fmt.Fprintf(&sb, ", algorithm=%s", alg)
   262  	}
   263  	_, _ = fmt.Fprintf(&sb, ", response=\"%x\", opaque=\"%s\"", response, params["opaque"])
   264  
   265  	req.Header.Add("Authorization", sb.String())
   266  
   267  	return true
   268  }
   269  
   270  func (auth *HTTPAuthentication) parseChallenge(challenge string) map[string]string {
   271  	// Parse the params out of the auth challenge header.
   272  	// e.g. Digest realm="testrealm@host.com", qop="auth,auth-int" ->
   273  	// {"realm": "testrealm@host.com", "qop", "auth,auth-int"}
   274  	//
   275  	// This isn't perfectly robust - some edge cases / weird headers may parse incorrectly.
   276  
   277  	// Get rid of "Digest" prefix
   278  	_, challenge, _ = strings.Cut(challenge, " ")
   279  
   280  	parts := strings.Split(challenge, ",")
   281  	// parts may have had a quoted comma, recombine if there's an unclosed quote.
   282  
   283  	for i := 0; i < len(parts); {
   284  		if strings.Count(parts[i], "\"")%2 == 1 && len(parts) > i+1 {
   285  			parts[i] = parts[i] + "," + parts[i+1]
   286  			parts = append(parts[:i+1], parts[i+2:]...)
   287  
   288  			continue
   289  		}
   290  		i++
   291  	}
   292  
   293  	m := make(map[string]string)
   294  	for _, part := range parts {
   295  		key, val, _ := strings.Cut(part, "=")
   296  		key = strings.Trim(key, " ")
   297  		val = strings.Trim(val, " ")
   298  		// remove quotes from quoted string
   299  		val = strings.Trim(val, "\"")
   300  		m[key] = val
   301  	}
   302  
   303  	return m
   304  }
   305  
   306  func (auth *HTTPAuthentication) cnonce() string {
   307  	if auth.CnonceFunc != nil {
   308  		return auth.CnonceFunc()
   309  	}
   310  
   311  	// for a default nonce use a random 8 bytes
   312  	b := make([]byte, 8)
   313  	if _, err := rand.Read(b); err != nil {
   314  		return ""
   315  	}
   316  
   317  	return hex.EncodeToString(b)
   318  }