From 896bc5c9443caf8a2fd3bd801bfbee60b5c7f349 Mon Sep 17 00:00:00 2001 From: "Brett V. Forsgren" Date: Fri, 6 Mar 2026 11:21:27 -0700 Subject: [PATCH 1/2] allow for basic auth override for non-host matching jit request --- internal/apiclient/client.go | 7 +- internal/apiclient/client_test.go | 6 +- internal/handlers/git_server.go | 24 ++++-- internal/handlers/git_server_test.go | 105 ++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 8f82fb8..f40ebcf 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -98,7 +98,9 @@ func WithTransport(transport *http.Transport) ClientOpt { } // RequestJITAccess asks the API to create a token with access to the specified repository. -func (c *Client) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, account string, repo string) (*config.Credential, error) { +// If username and password are provided, they are used for basic auth; otherwise the client's +// default token is used in the Authorization header. +func (c *Client) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, username string, password string, account string, repo string) (*config.Credential, error) { url := c.newURL("%s", endpoint) if err := c.jitRateLimit.Acquire(ctx.Req.Context(), 1); err != nil { @@ -116,6 +118,9 @@ func (c *Client) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, accoun if err != nil { return nil, err } + if username != "" && password != "" { + req.SetBasicAuth(username, password) + } rsp, err := c.httpClient.Do(req) if err != nil { diff --git a/internal/apiclient/client_test.go b/internal/apiclient/client_test.go index 17c5b91..6793da0 100644 --- a/internal/apiclient/client_test.go +++ b/internal/apiclient/client_test.go @@ -145,7 +145,7 @@ func TestClient_RequestJITAccess(t *testing.T) { ctx := &goproxy.ProxyCtx{ Req: httptest.NewRequest("GET", "https://example.com", nil), } - result, err := client.RequestJITAccess(ctx, jitAccessEndpoint, accountName, repoName) + result, err := client.RequestJITAccess(ctx, jitAccessEndpoint, "", "", accountName, repoName) assert.NoError(t, err) assert.Equal(t, &config.Credential{"username": "username", "password": "password"}, result) @@ -163,7 +163,7 @@ func TestClient_RequestJITAccess(t *testing.T) { ctx := &goproxy.ProxyCtx{ Req: httptest.NewRequest("GET", "https://example.com", nil), } - _, err := client.RequestJITAccess(ctx, "/endpoint", "this", "repo") + _, err := client.RequestJITAccess(ctx, "/endpoint", "", "", "this", "repo") assert.Equal(t, "failed to request additional scope Not Implemented", err.Error()) }) @@ -209,7 +209,7 @@ func TestClient_RequestJITAccess(t *testing.T) { ctx := &goproxy.ProxyCtx{ Req: httptest.NewRequest("GET", "https://example.com", nil), } - credential, err := client.RequestJITAccess(ctx, "/endpoint", "account", "repo-"+requestNumber) + credential, err := client.RequestJITAccess(ctx, "/endpoint", "", "", "account", "repo-"+requestNumber) require.NoError(t, err) assert.Equal(t, "world-"+requestNumber, (*credential)["hello"], "Response should contain request number") } diff --git a/internal/handlers/git_server.go b/internal/handlers/git_server.go index 6de8fb3..784aeb9 100644 --- a/internal/handlers/git_server.go +++ b/internal/handlers/git_server.go @@ -21,12 +21,18 @@ import ( // github.com or private git servers type GitServerHandler struct { credentials *gitCredentialsMap - jitAccessByHost map[string]string + jitAccessByHost map[string]jitAccessConfig client ScopeRequester reposAlreadyTried *threadsafe.Map[string, struct{}] } +type jitAccessConfig struct { + endpoint string + username string + password string +} + type gitCredentialsMap struct { sync.RWMutex // data is a nested map structure to store credentials. @@ -213,7 +219,7 @@ const ( ) type ScopeRequester interface { - RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, account string, repo string) (*config.Credential, error) + RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, username string, password string, account string, repo string) (*config.Credential, error) } // NewGitServerHandler returns a new GitServerHandler, adding basic auth to @@ -221,7 +227,7 @@ type ScopeRequester interface { func NewGitServerHandler(creds config.Credentials, client ScopeRequester) *GitServerHandler { handler := GitServerHandler{ credentials: newGitCredentialsMap(), - jitAccessByHost: map[string]string{}, + jitAccessByHost: map[string]jitAccessConfig{}, client: client, reposAlreadyTried: threadsafe.NewMap[string, struct{}](), } @@ -252,7 +258,11 @@ func (h *GitServerHandler) addJITAccess(cred config.Credential) { } host := strings.ToLower(cred.GetString("host")) - h.jitAccessByHost[host] = cred.GetString("endpoint") + h.jitAccessByHost[host] = jitAccessConfig{ + endpoint: cred.GetString("endpoint"), + username: cred.GetString("username"), + password: cred.GetString("password"), + } } // HandleRequest adds auth to a git server request @@ -480,8 +490,8 @@ func (h *GitServerHandler) requestWithAlternativeAuth(ctx *goproxy.ProxyCtx, bod func (h *GitServerHandler) getJITCredentialsForRequest(ctx *goproxy.ProxyCtx) *gitCredentials { host := helpers.GetHost(ctx.Req) - endpoint := h.jitAccessByHost[host] - if endpoint == "" { + jitConfig := h.jitAccessByHost[host] + if jitConfig.endpoint == "" { return nil } @@ -494,7 +504,7 @@ func (h *GitServerHandler) getJITCredentialsForRequest(ctx *goproxy.ProxyCtx) *g if h.client == nil { return nil } - credential, err := h.client.RequestJITAccess(ctx, endpoint, org, repo) + credential, err := h.client.RequestJITAccess(ctx, jitConfig.endpoint, jitConfig.username, jitConfig.password, org, repo) if credential == nil || err != nil { return nil } diff --git a/internal/handlers/git_server_test.go b/internal/handlers/git_server_test.go index 896c12d..10921e1 100644 --- a/internal/handlers/git_server_test.go +++ b/internal/handlers/git_server_test.go @@ -9,11 +9,14 @@ import ( "net/url" "strings" "testing" + "time" "github.com/elazarl/goproxy" + "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/dependabot/proxy/internal/apiclient" "github.com/dependabot/proxy/internal/config" "github.com/dependabot/proxy/internal/ctxdata" ) @@ -510,7 +513,7 @@ type TestScopeRequester struct { const jitToken = "newToken" -func (t *TestScopeRequester) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, account string, repo string) (*config.Credential, error) { +func (t *TestScopeRequester) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, username string, password string, account string, repo string) (*config.Credential, error) { t.receivedRequest = true return &config.Credential{ @@ -570,3 +573,103 @@ func TestGitServerHandler_RequestJITAccess(t *testing.T) { }) } } + +func TestJITEndpointUsesExplicitAuthWhenProvided(t *testing.T) { + // jit_access host is different from the endpoint host _AND_ requires separate auth + // This test verifies that the explicit username and password that accompany the jit_access cred are used + creds := []config.Credential{ + { + "type": "git_source", + "host": "github.com", + "username": "x-access-token", + "password": "this-token-has-expired", + }, + { + "type": "jit_access", + "credential-type": "git_source", + "host": "github.com", // if requests to github.com fail we need to use the other endpoint and auth to get a new token + "endpoint": "https://dpdbot.dev.azure.com/some/path/dependabot/job/1234/jit_access", + "username": "x-access-token", + "password": "explicit-jit-access-token", + }, + } + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // The expected order is: + // 1. Request to github.com is made with expired token and fails with 401 + // 2. Request to jit_access endpoint is made with explicit credentials and returns new token + // 3. Original request is retried with new token and succeeds + + urlsRequested := []string{} + httpmock.RegisterResponder("GET", "https://github.com/account/repo/info/refs?service=git-upload-pack", func(req *http.Request) (*http.Response, error) { + _, pass, _ := req.BasicAuth() + if pass == "this-token-has-expired" { + urlsRequested = append(urlsRequested, fmt.Sprintf("%d|%s", 401, req.URL.String())) + return &http.Response{ + StatusCode: 401, + Body: io.NopCloser(strings.NewReader("token has expired")), + }, nil + } + + if pass == "refreshed-token" { + urlsRequested = append(urlsRequested, fmt.Sprintf("%d|%s", 200, req.URL.String())) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("success!")), + }, nil + } + + urlsRequested = append(urlsRequested, fmt.Sprintf("%d|%s", 400, req.URL.String())) + return &http.Response{ + StatusCode: 400, + Body: io.NopCloser(strings.NewReader("unexpected")), + }, nil + }) + + httpmock.RegisterResponder("POST", "https://dpdbot.dev.azure.com/some/path/dependabot/job/1234/jit_access", func(req *http.Request) (*http.Response, error) { + urlsRequested = append(urlsRequested, fmt.Sprintf("%d|%s", 200, req.URL.String())) + user, pass, ok := req.BasicAuth() + assert.True(t, ok, "should have basic auth on JIT access request") + assert.Equal(t, "x-access-token", user, "should have used explicit username for JIT access request") + assert.Equal(t, "explicit-jit-access-token", pass, "should have used explicit password for JIT access request") + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{"username":"x-access-token","password":"refreshed-token"}`)), + }, nil + }) + + apiClient := apiclient.New("", "job-token-is-wrong-token", "") // other token given for the API client + handler := NewGitServerHandler(creds, apiClient) + + // this is the actual network request + req, err := http.NewRequest("GET", "https://github.com/account/repo/info/refs?service=git-upload-pack", nil) + require.NoError(t, err) + + // RoundTripper delegates to http.DefaultTransport so retries go through httpmock + roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Response, error) { + return http.DefaultTransport.RoundTrip(r) + }) + proxyCtx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + + req, _ = handler.HandleRequest(req, proxyCtx) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + require.NoError(t, err) + + // HandleResponse triggers retry logic including JIT credential refresh + resp = handler.HandleResponse(resp, proxyCtx) + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + assert.Equal(t, "success!", bodyStr, "expected successful response body") + assert.Equal(t, []string{ + "401|https://github.com/account/repo/info/refs?service=git-upload-pack", + "200|https://dpdbot.dev.azure.com/some/path/dependabot/job/1234/jit_access", + "200|https://github.com/account/repo/info/refs?service=git-upload-pack", + }, urlsRequested, "expected request flow: github.com (401), jit_access refresh (200), github.com (200)") +} From f02552924df6d5cacc2a94c5e94b6038ae35dcd3 Mon Sep 17 00:00:00 2001 From: "Brett V. Forsgren" Date: Fri, 6 Mar 2026 12:07:11 -0700 Subject: [PATCH 2/2] apply suggested test fixes --- internal/handlers/git_server_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/handlers/git_server_test.go b/internal/handlers/git_server_test.go index 10921e1..d432a8a 100644 --- a/internal/handlers/git_server_test.go +++ b/internal/handlers/git_server_test.go @@ -644,7 +644,7 @@ func TestJITEndpointUsesExplicitAuthWhenProvided(t *testing.T) { handler := NewGitServerHandler(creds, apiClient) // this is the actual network request - req, err := http.NewRequest("GET", "https://github.com/account/repo/info/refs?service=git-upload-pack", nil) + req, err := http.NewRequestWithContext(context.Background(), "GET", "https://github.com/account/repo/info/refs?service=git-upload-pack", nil) require.NoError(t, err) // RoundTripper delegates to http.DefaultTransport so retries go through httpmock @@ -663,6 +663,7 @@ func TestJITEndpointUsesExplicitAuthWhenProvided(t *testing.T) { // HandleResponse triggers retry logic including JIT credential refresh resp = handler.HandleResponse(resp, proxyCtx) + defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) bodyStr := string(body)