Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/apiclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
})
Expand Down Expand Up @@ -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")
}
Expand Down
24 changes: 17 additions & 7 deletions internal/handlers/git_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -213,15 +219,15 @@ 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
// requests to hosts for which we have credentials
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{}](),
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand Down
106 changes: 105 additions & 1 deletion internal/handlers/git_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -570,3 +573,104 @@ 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.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
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)
defer resp.Body.Close()

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)")
}
Loading