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 }