github.com/google/osv-scalibr@v0.4.1/clients/datasource/http_auth_test.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_test
    16  
    17  import (
    18  	"net/http"
    19  	"testing"
    20  
    21  	"github.com/google/osv-scalibr/clients/datasource"
    22  )
    23  
    24  // mockTransport is used to inspect the requests being made by HTTPAuthentications
    25  type mockTransport struct {
    26  	Requests         []*http.Request // All requests made to this transport
    27  	UnauthedResponse *http.Response  // Response sent when request does not have an 'Authorization' header.
    28  	AuthedReponse    *http.Response  // Response to sent when request does include 'Authorization' (not checked).
    29  }
    30  
    31  func (mt *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    32  	mt.Requests = append(mt.Requests, req)
    33  	var resp *http.Response
    34  	if req.Header.Get("Authorization") == "" {
    35  		resp = mt.UnauthedResponse
    36  	} else {
    37  		resp = mt.AuthedReponse
    38  	}
    39  	if resp == nil {
    40  		resp = &http.Response{StatusCode: http.StatusOK}
    41  	}
    42  
    43  	return resp, nil
    44  }
    45  
    46  func TestHTTPAuthentication(t *testing.T) {
    47  	tests := []struct {
    48  		name                  string
    49  		httpAuth              *datasource.HTTPAuthentication
    50  		requestURL            string
    51  		wwwAuth               []string
    52  		expectedAuths         []string // expected Authentication headers received.
    53  		expectedResponseCodes []int    // expected final response codes received (length may be less than expectedAuths)
    54  	}{
    55  		{
    56  			name:                  "nil auth",
    57  			httpAuth:              nil,
    58  			requestURL:            "http://127.0.0.1/",
    59  			wwwAuth:               []string{"Basic"},
    60  			expectedAuths:         []string{""},
    61  			expectedResponseCodes: []int{http.StatusUnauthorized},
    62  		},
    63  		{
    64  			name:                  "default auth",
    65  			httpAuth:              &datasource.HTTPAuthentication{},
    66  			requestURL:            "http://127.0.0.1/",
    67  			wwwAuth:               []string{"Basic"},
    68  			expectedAuths:         []string{""},
    69  			expectedResponseCodes: []int{http.StatusUnauthorized},
    70  		},
    71  		{
    72  			name: "basic_auth",
    73  			httpAuth: &datasource.HTTPAuthentication{
    74  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic},
    75  				AlwaysAuth:       true,
    76  				Username:         "Aladdin",
    77  				Password:         "open sesame",
    78  			},
    79  			requestURL:            "http://127.0.0.1/",
    80  			expectedAuths:         []string{"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="},
    81  			expectedResponseCodes: []int{http.StatusOK},
    82  		},
    83  		{
    84  			name: "basic_auth_from_token",
    85  			httpAuth: &datasource.HTTPAuthentication{
    86  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic},
    87  				AlwaysAuth:       true,
    88  				Username:         "ignored",
    89  				Password:         "ignored",
    90  				BasicAuth:        "QWxhZGRpbjpvcGVuIHNlc2FtZQ==",
    91  			},
    92  			requestURL:            "http://127.0.0.1/",
    93  			expectedAuths:         []string{"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="},
    94  			expectedResponseCodes: []int{http.StatusOK},
    95  		},
    96  		{
    97  			name: "basic_auth_missing_username",
    98  			httpAuth: &datasource.HTTPAuthentication{
    99  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic},
   100  				AlwaysAuth:       true,
   101  				Username:         "",
   102  				Password:         "ignored",
   103  			},
   104  			requestURL:            "http://127.0.0.1/",
   105  			expectedAuths:         []string{""},
   106  			expectedResponseCodes: []int{http.StatusOK},
   107  		},
   108  		{
   109  			name: "basic_auth_missing_password",
   110  			httpAuth: &datasource.HTTPAuthentication{
   111  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic},
   112  				AlwaysAuth:       true,
   113  				Username:         "ignored",
   114  				Password:         "",
   115  			},
   116  			requestURL:            "http://127.0.0.1/",
   117  			expectedAuths:         []string{""},
   118  			expectedResponseCodes: []int{http.StatusOK},
   119  		},
   120  		{
   121  			name: "basic_auth_not_always",
   122  			httpAuth: &datasource.HTTPAuthentication{
   123  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic},
   124  				AlwaysAuth:       false,
   125  				BasicAuth:        "YTph",
   126  			},
   127  			requestURL:            "http://127.0.0.1/",
   128  			wwwAuth:               []string{"Basic realm=\"User Visible Realm\""},
   129  			expectedAuths:         []string{"", "Basic YTph"},
   130  			expectedResponseCodes: []int{http.StatusOK},
   131  		},
   132  		{
   133  			name: "bearer_auth",
   134  			httpAuth: &datasource.HTTPAuthentication{
   135  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBearer},
   136  				AlwaysAuth:       true,
   137  				BearerToken:      "abcdefgh",
   138  			},
   139  			requestURL:            "http://127.0.0.1/",
   140  			expectedAuths:         []string{"Bearer abcdefgh"},
   141  			expectedResponseCodes: []int{http.StatusOK},
   142  		},
   143  		{
   144  			name: "bearer_auth_not_always",
   145  			httpAuth: &datasource.HTTPAuthentication{
   146  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBearer},
   147  				AlwaysAuth:       false,
   148  				BearerToken:      "abcdefgh",
   149  			},
   150  			requestURL:            "http://127.0.0.1/",
   151  			wwwAuth:               []string{"Bearer"},
   152  			expectedAuths:         []string{"", "Bearer abcdefgh"},
   153  			expectedResponseCodes: []int{http.StatusOK},
   154  		},
   155  		{
   156  			name: "always_auth_priority",
   157  			httpAuth: &datasource.HTTPAuthentication{
   158  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBasic, datasource.AuthBearer},
   159  				AlwaysAuth:       true,
   160  				BasicAuth:        "UseThisOne",
   161  				BearerToken:      "NotThisOne",
   162  			},
   163  			requestURL:            "http://127.0.0.1/",
   164  			expectedAuths:         []string{"Basic UseThisOne"},
   165  			expectedResponseCodes: []int{http.StatusOK},
   166  		},
   167  		{
   168  			name: "not_always_auth_priority",
   169  			httpAuth: &datasource.HTTPAuthentication{
   170  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthBearer, datasource.AuthDigest, datasource.AuthBasic},
   171  				AlwaysAuth:       false,
   172  				Username:         "DoNotUse",
   173  				Password:         "ThisField",
   174  				BearerToken:      "PleaseUseThis",
   175  			},
   176  			requestURL:            "http://127.0.0.1/",
   177  			wwwAuth:               []string{"Basic", "Bearer"},
   178  			expectedAuths:         []string{"", "Bearer PleaseUseThis"},
   179  			expectedResponseCodes: []int{http.StatusOK},
   180  		},
   181  		{
   182  			name: "digest_auth",
   183  			// Example from https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation
   184  			httpAuth: &datasource.HTTPAuthentication{
   185  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthDigest},
   186  				AlwaysAuth:       false,
   187  				Username:         "Mufasa",
   188  				Password:         "Circle Of Life",
   189  				CnonceFunc:       func() string { return "0a4f113b" },
   190  			},
   191  			requestURL: "https://127.0.0.1/dir/index.html",
   192  			wwwAuth: []string{
   193  				"Digest realm=\"testrealm@host.com\", " +
   194  					"qop=\"auth,auth-int\", " +
   195  					"nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " +
   196  					"opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
   197  			},
   198  			expectedAuths: []string{
   199  				"",
   200  				// The order of these fields shouldn't actually matter
   201  				"Digest username=\"Mufasa\", " +
   202  					"realm=\"testrealm@host.com\", " +
   203  					"nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " +
   204  					"uri=\"/dir/index.html\", " +
   205  					"qop=auth, " +
   206  					"nc=00000001, " +
   207  					"cnonce=\"0a4f113b\", " +
   208  					"response=\"6629fae49393a05397450978507c4ef1\", " +
   209  					"opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
   210  			},
   211  			expectedResponseCodes: []int{http.StatusOK},
   212  		},
   213  		{
   214  			name: "digest_auth_rfc2069", // old spec, without qop header
   215  			httpAuth: &datasource.HTTPAuthentication{
   216  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthDigest},
   217  				AlwaysAuth:       false,
   218  				Username:         "Mufasa",
   219  				Password:         "Circle Of Life",
   220  			},
   221  			requestURL: "https://127.0.0.1/dir/index.html",
   222  			wwwAuth: []string{
   223  				"Digest realm=\"testrealm@host.com\", " +
   224  					"nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " +
   225  					"opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
   226  			},
   227  			expectedAuths: []string{
   228  				"",
   229  				// The order of these fields shouldn't actually matter
   230  				"Digest username=\"Mufasa\", " +
   231  					"realm=\"testrealm@host.com\", " +
   232  					"nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " +
   233  					"uri=\"/dir/index.html\", " +
   234  					"response=\"670fd8c2df070c60b045671b8b24ff02\", " +
   235  					"opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
   236  			},
   237  			expectedResponseCodes: []int{http.StatusOK},
   238  		},
   239  		{
   240  			name: "digest_auth_mvn",
   241  			// From what mvn sends.
   242  			httpAuth: &datasource.HTTPAuthentication{
   243  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthDigest},
   244  				AlwaysAuth:       false,
   245  				Username:         "my-username",
   246  				Password:         "cool-password",
   247  				CnonceFunc:       func() string { return "f7ef2d457dabcd54" },
   248  			},
   249  			requestURL: "https://127.0.0.1:41565/commons-io/commons-io/1.0/commons-io-1.0.pom",
   250  			wwwAuth: []string{
   251  				"Digest realm=\"test@osv.dev\"," +
   252  					"qop=\"auth\"," +
   253  					"nonce=\"deadbeef\"," +
   254  					"opaque=\"aaaa\"," +
   255  					"algorithm=\"MD5-sess\"," +
   256  					"domain=\"/test\"",
   257  			},
   258  			expectedAuths: []string{
   259  				"",
   260  				// The order of these fields shouldn't actually matter
   261  				"Digest username=\"my-username\", " +
   262  					"realm=\"test@osv.dev\", " +
   263  					"nonce=\"deadbeef\", " +
   264  					"uri=\"/commons-io/commons-io/1.0/commons-io-1.0.pom\", " +
   265  					"qop=auth, " +
   266  					"nc=00000001, " +
   267  					"cnonce=\"f7ef2d457dabcd54\", " +
   268  					"algorithm=MD5-sess, " +
   269  					"response=\"15a35e7018a0fc7db05d31185e0d2c9e\", " +
   270  					"opaque=\"aaaa\"",
   271  			},
   272  			expectedResponseCodes: []int{http.StatusOK},
   273  		},
   274  
   275  		{
   276  			name: "basic_auth_reuse_on_subsequent",
   277  			httpAuth: &datasource.HTTPAuthentication{
   278  				SupportedMethods: []datasource.HTTPAuthMethod{datasource.AuthDigest, datasource.AuthBasic},
   279  				AlwaysAuth:       false,
   280  				Username:         "user",
   281  				Password:         "pass",
   282  			},
   283  			requestURL:            "http://127.0.0.1/",
   284  			wwwAuth:               []string{"Basic realm=\"Realm\""},
   285  			expectedAuths:         []string{"", "Basic dXNlcjpwYXNz", "Basic dXNlcjpwYXNz"},
   286  			expectedResponseCodes: []int{http.StatusOK, http.StatusOK},
   287  		},
   288  	}
   289  
   290  	for _, tt := range tests {
   291  		t.Run(tt.name, func(t *testing.T) {
   292  			mt := &mockTransport{}
   293  			if len(tt.wwwAuth) > 0 {
   294  				mt.UnauthedResponse = &http.Response{
   295  					StatusCode: http.StatusUnauthorized,
   296  					Header:     make(http.Header),
   297  				}
   298  				for _, v := range tt.wwwAuth {
   299  					mt.UnauthedResponse.Header.Add("WWW-Authenticate", v)
   300  				}
   301  			}
   302  			httpClient := &http.Client{Transport: mt}
   303  			for _, want := range tt.expectedResponseCodes {
   304  				resp, err := tt.httpAuth.Get(t.Context(), httpClient, tt.requestURL)
   305  				if err != nil {
   306  					t.Fatalf("error making request: %v", err)
   307  				}
   308  				defer resp.Body.Close()
   309  				if resp.StatusCode != want {
   310  					t.Errorf("authorization response status code got = %d, want %d", resp.StatusCode, want)
   311  				}
   312  			}
   313  			if len(mt.Requests) != len(tt.expectedAuths) {
   314  				t.Fatalf("unexpected number of requests got = %d, want %d", len(mt.Requests), len(tt.expectedAuths))
   315  			}
   316  			for i, want := range tt.expectedAuths {
   317  				got := mt.Requests[i].Header.Get("Authorization")
   318  				if got != want {
   319  					t.Errorf("authorization header got = \"%s\", want \"%s\"", got, want)
   320  				}
   321  			}
   322  		})
   323  	}
   324  }