diff --git a/internal/handlers/github_api.go b/internal/handlers/github_api.go index 98c4d08..be13eaa 100644 --- a/internal/handlers/github_api.go +++ b/internal/handlers/github_api.go @@ -13,13 +13,18 @@ import ( "github.com/dependabot/proxy/internal/ctxdata" "github.com/dependabot/proxy/internal/helpers" "github.com/dependabot/proxy/internal/logging" + "github.com/dependabot/proxy/internal/threadsafe" ) // GitHubAPIHandler handles requests destined for the GitHub API, adding auth // This allows git credentials for "github.com" to apply to "api.github.com" and // will allow git credentials for ".ghe.com" to apply to "api..ghe.com" in Proxima. type GitHubAPIHandler struct { - credentials *gitCredentialsMap + credentials *gitCredentialsMap + jitAccessByHost map[string]jitAccessConfig + client ScopeRequester + + reposAlreadyTried *threadsafe.Map[string, struct{}] } const ghAPIAddedAuthCtxKey = "gh-api.added-auth" @@ -27,37 +32,67 @@ const reservedProximaIdentity = "proxima-service-identity" // NewGitHubAPIHandler returns a new GitHubAPIHandler, extracting the app // access token from the array of credentials -func NewGitHubAPIHandler(creds config.Credentials) *GitHubAPIHandler { +func NewGitHubAPIHandler(creds config.Credentials, client ScopeRequester) *GitHubAPIHandler { handler := GitHubAPIHandler{ - credentials: newGitCredentialsMap(), + credentials: newGitCredentialsMap(), + jitAccessByHost: map[string]jitAccessConfig{}, + client: client, + reposAlreadyTried: threadsafe.NewMap[string, struct{}](), } + hasGitSourceCredentials := false for _, cred := range creds { - host := cred.Host() - if host == "" { - continue - } - if cred["type"] != "git_source" || (host != "github.com" && !(strings.HasSuffix(fmt.Sprint(host), ".ghe.com"))) { - continue + switch cred["type"] { + case "git_source": + host := cred.Host() + if host == "" { + continue + } + if host != "github.com" && !strings.HasSuffix(fmt.Sprint(host), ".ghe.com") { + continue + } + handler.credentials.addGitSourceCredentials("api."+host, cred) + hasGitSourceCredentials = true + case "jit_access": + if client != nil { + handler.addJITAccess(cred) + } } - handler.credentials.addGitSourceCredentials("api."+host, cred) } - if len(handler.credentials.data) == 0 { + if !hasGitSourceCredentials && len(handler.jitAccessByHost) == 0 { logrus.Warn("GitHubAPIHandler has no app access tokens") } return &handler } +func (h *GitHubAPIHandler) addJITAccess(cred config.Credential) { + if cred.GetString("credential-type") != "git_source" { + return + } + + host := strings.ToLower(cred.GetString("host")) + if host == "" { + return + } + + if !strings.HasPrefix(host, "api.") { + host = "api." + host + } + + h.jitAccessByHost[host] = jitAccessConfig{ + endpoint: cred.GetString("endpoint"), + username: cred.GetString("username"), + password: cred.GetString("password"), + } +} + // HandleRequest adds auth to a GitHub API request func (h *GitHubAPIHandler) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { if !h.isHandledGitHubAPIRequest(req) { return req, nil } - if len(h.credentials.data) == 0 { - return req, nil - } host := helpers.GetHost(req) creds := getCredentialsForRequest(req, h.credentials, gitHubAPIExtractOrgAndRepo) @@ -84,15 +119,23 @@ func (h *GitHubAPIHandler) HandleResponse(rsp *http.Response, ctx *goproxy.Proxy if rsp == nil { return rsp } - if addedAuth, ok := ctxdata.GetBool(ctx, ghAPIAddedAuthCtxKey); !ok || !addedAuth { + if !h.isHandledGitHubAPIRequest(ctx.Req) { return rsp } - if !h.isHandledGitHubAPIRequest(ctx.Req) { + addedAuth, _ := ctxdata.GetBool(ctx, ghAPIAddedAuthCtxKey) + if !addedAuth { return rsp } if !isPotentialAuthFailure(rsp.StatusCode) { return rsp } + repoKey, hasRepo := h.jitRepositoryKey(ctx.Req) + if hasRepo { + if _, ok := h.reposAlreadyTried.Get(repoKey); ok { + logging.RequestLogf(ctx, "* github api repository previously retried, won't retry again") + return rsp + } + } username, password, reqWasAuthed := ctx.Req.BasicAuth() for _, creds := range getCredentialsForRequest(ctx.Req, h.credentials, gitHubAPIExtractOrgAndRepo) { @@ -121,9 +164,76 @@ func (h *GitHubAPIHandler) HandleResponse(rsp *http.Response, ctx *goproxy.Proxy logging.RequestLogf(ctx, "* re-auth'd request returned %d, ignoring response", newRsp.StatusCode) helpers.DrainAndClose(newRsp) } + + // All known credentials have been tried, try to JIT create access credentials. + jitCreds := h.getJITCredentialsForRequest(ctx) + if jitCreds != nil { + newReq := ctx.Req.Clone(ctx.Req.Context()) + logging.RequestLogf(ctx, "* auth'd github api request failed authentication, retrying with jit access auth") + newReq.Header.Del("Authorization") + newReq.Header.Del("X-GitHub-PSI-JWT") + if jitCreds.username == reservedProximaIdentity { + newReq.Header.Set("X-GitHub-PSI-JWT", jitCreds.password) + } else { + newReq.Header.Set("Authorization", "token "+jitCreds.password) + } + newRsp, err := ctx.RoundTrip(newReq) + if err != nil { + return rsp + } + + if !isPotentialAuthFailure(newRsp.StatusCode) { + helpers.DrainAndClose(rsp) + logging.RequestLogf(ctx, "* re-auth'd jit request returned %d, replacing response", newRsp.StatusCode) + return newRsp + } + logging.RequestLogf(ctx, "* re-auth'd jit request returned %d, ignoring response", newRsp.StatusCode) + helpers.DrainAndClose(newRsp) + } + if hasRepo { + h.reposAlreadyTried.Set(repoKey, struct{}{}) + } + return rsp } +func (h *GitHubAPIHandler) getJITCredentialsForRequest(ctx *goproxy.ProxyCtx) *gitCredentials { + host := helpers.GetHost(ctx.Req) + jitConfig := h.jitAccessByHost[host] + if jitConfig.endpoint == "" { + return nil + } + + org, repo, ok := gitHubAPIExtractOrgAndRepo(ctx.Req.URL.Path) + if !ok { + return nil + } + + if h.client == nil { + return nil + } + logging.RequestLogf(ctx, "* requesting JIT access for github api request") + credential, err := h.client.RequestJITAccess(ctx, jitConfig.endpoint, jitConfig.username, jitConfig.password, org, repo) + if credential == nil || err != nil { + return nil + } + + repoNWO := fmt.Sprintf("%s/%s", org, repo) + + // Add the returned credentials to the beginning of the repo-scoped list, so that + // they are prioritized over existing tokens for future requests. + hostCreds := h.credentials.get(host) + return hostCreds.addToken(repoNWO, credential.GetString("username"), credential.GetString("password"), true) +} + +func (h *GitHubAPIHandler) jitRepositoryKey(req *http.Request) (string, bool) { + org, repo, ok := gitHubAPIExtractOrgAndRepo(req.URL.Path) + if !ok { + return "", false + } + return fmt.Sprintf("%s/%s/%s", helpers.GetHost(req), org, repo), true +} + func (h *GitHubAPIHandler) isHandledGitHubAPIRequest(req *http.Request) bool { return req.URL.Scheme == "https" && helpers.MethodPermitted(req, "GET", "HEAD") && helpers.CheckGitHubAPIHost(req) } diff --git a/internal/handlers/github_api_test.go b/internal/handlers/github_api_test.go index b4759f2..3a08eae 100644 --- a/internal/handlers/github_api_test.go +++ b/internal/handlers/github_api_test.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "fmt" "io" "net/http" @@ -24,7 +25,7 @@ func TestGitHubAPIHandler_withUrlFallback(t *testing.T) { "password": "super-secret-token", }} - handler := NewGitHubAPIHandler(usingURL) + handler := NewGitHubAPIHandler(usingURL, nil) req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) req = handleRequestAndClose(handler, req, nil) @@ -59,7 +60,7 @@ func TestGitHubAPIHandler(t *testing.T) { bitBucketCred, rubygemsCred, } - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Valid API request, prioritises non-installation token req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) @@ -84,7 +85,7 @@ func TestGitHubAPIHandler(t *testing.T) { // With only the installation GitHub token credentials := config.Credentials{installationCred, bitBucketCred, rubygemsCred} - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Valid API request, uses installation token req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) @@ -93,7 +94,7 @@ func TestGitHubAPIHandler(t *testing.T) { // With only the proxima token credentials = config.Credentials{proximaCred, bitBucketCred, rubygemsCred} - handler = NewGitHubAPIHandler(credentials) + handler = NewGitHubAPIHandler(credentials, nil) // Valid API request, uses installation token req = httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) @@ -153,7 +154,7 @@ func TestGitHubAPIHandler_AuthenticatedAccessToGitHubRepos(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewGitHubAPIHandler(tt.credentials) + handler := NewGitHubAPIHandler(tt.credentials, nil) // Valid github git request, prioritises non-installation token req := httptest.NewRequest("GET", fmt.Sprintf("https://api.github.com/%s", tt.repoNWO), nil) @@ -197,7 +198,7 @@ func TestGitHubAPIHandlerInProxima(t *testing.T) { bitBucketCred, rubygemsCred, } - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Valid API request, prioritises non-installation token req := httptest.NewRequest("GET", "https://api.foo.ghe.com/some-repo", nil) @@ -222,7 +223,7 @@ func TestGitHubAPIHandlerInProxima(t *testing.T) { // With only the installation GitHub token credentials := config.Credentials{installationCred, bitBucketCred, rubygemsCred} - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Valid API request, uses installation token req := httptest.NewRequest("GET", "https://api.foo.ghe.com/some-repo", nil) @@ -250,7 +251,7 @@ func TestGitHubAPIHandlerWithMulipleHosts(t *testing.T) { tt.personalAccessToken, fooGheCred, } - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Request to github.com, using the correct token req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) @@ -271,7 +272,7 @@ func TestGitHubAPIHandlerWithMulipleHosts(t *testing.T) { // With only the github.com token credentials := config.Credentials{githubCred} - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) // Valid API request, uses only github.com token req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) @@ -280,7 +281,7 @@ func TestGitHubAPIHandlerWithMulipleHosts(t *testing.T) { // With only the foo.ghe.com token fooGheCredentials := config.Credentials{fooGheCred} - fooGhehandler := NewGitHubAPIHandler(fooGheCredentials) + fooGhehandler := NewGitHubAPIHandler(fooGheCredentials, nil) // Valid API request, uses only foo.ghe.com token fooGheReq := httptest.NewRequest("GET", "https://api.foo.ghe.com/some-repo", nil) @@ -291,7 +292,7 @@ func TestGitHubAPIHandlerWithMulipleHosts(t *testing.T) { func TestGitHubAPIHandler_InstallationTokenFormat(t *testing.T) { installationCred := testGitSourceCred("github.com", "x-access-token", "ghs_fakefakefakesuperfake") credentials := config.Credentials{installationCred} - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) req := httptest.NewRequest("GET", "https://api.github.com/some-repo", nil) req = handleRequestAndClose(handler, req, nil) @@ -301,7 +302,7 @@ func TestGitHubAPIHandler_InstallationTokenFormat(t *testing.T) { func TestGitHubAPIHandler_InstallationTokenFormat_Proxima(t *testing.T) { installationCred := testGitSourceCred("foo.ghe.com", "x-access-token", "ghs_fakefakefakesuperfake") credentials := config.Credentials{installationCred} - handler := NewGitHubAPIHandler(credentials) + handler := NewGitHubAPIHandler(credentials, nil) req := httptest.NewRequest("GET", "https://api.foo.ghe.com/some-repo", nil) req = handleRequestAndClose(handler, req, nil) @@ -321,7 +322,6 @@ func TestGitHubAPIHandler_TokenFallback(t *testing.T) { testGitSourceCred("github.com", "x-access-token", userToken1), testGitSourceCred("github.com", "x-access-token", userToken2), } - handler := NewGitHubAPIHandler(credentials) tests := []struct { name string @@ -390,6 +390,7 @@ func TestGitHubAPIHandler_TokenFallback(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + handler := NewGitHubAPIHandler(credentials, nil) var capturedTokens []string roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { token := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") @@ -438,7 +439,6 @@ func TestGitHubAPIHandler_TokenFallback_In_Proxima(t *testing.T) { testGitSourceCred("foo.ghe.com", "x-access-token", userToken1), testGitSourceCred("foo.ghe.com", "x-access-token", userToken2), } - handler := NewGitHubAPIHandler(credentials) url, err := url.Parse("https://api.foo.ghe.com/repos/github/dependabot-action") if err != nil { t.Errorf("parsing url: %v", err) @@ -496,6 +496,7 @@ func TestGitHubAPIHandler_TokenFallback_In_Proxima(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + handler := NewGitHubAPIHandler(credentials, nil) var capturedTokens []string roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { token := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") @@ -533,3 +534,168 @@ func TestGitHubAPIHandler_TokenFallback_In_Proxima(t *testing.T) { }) } } + +type testGitHubAPIScopeRequester struct { + callCount int + result *config.Credential + err error +} + +func (t *testGitHubAPIScopeRequester) RequestJITAccess(ctx *goproxy.ProxyCtx, endpoint string, username string, password string, account string, repo string) (*config.Credential, error) { + t.callCount++ + return t.result, t.err +} + +func TestGitHubAPIHandler_JITAccessFallback(t *testing.T) { + staticToken := "ghp_static" + jitToken := "ghp_jit" + credentials := config.Credentials{ + testGitSourceCred("github.com", "x-access-token", staticToken), + { + "type": "jit_access", + "credential-type": "git_source", + "host": "github.com", + "endpoint": "https://dependabot.example.com/jit_access", + }, + } + requester := &testGitHubAPIScopeRequester{ + result: &config.Credential{ + "username": "x-access-token", + "password": jitToken, + }, + } + handler := NewGitHubAPIHandler(credentials, requester) + + var capturedTokens []string + roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") + capturedTokens = append(capturedTokens, token) + if token == jitToken { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("jit-ok"))}, nil + } + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("not found"))}, nil + }) + + req := httptest.NewRequest("GET", "https://api.github.com:443/repos/example/internal-repo/releases?per_page=100", nil) + ctx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + rsp := &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("initial"))} + + _ = handleRequestAndClose(handler, req, ctx) + newRsp := handler.HandleResponse(rsp, ctx) + defer newRsp.Body.Close() + + body, err := io.ReadAll(newRsp.Body) + require.NoError(t, err) + assert.Equal(t, 200, newRsp.StatusCode) + assert.Equal(t, "jit-ok", string(body)) + assert.Equal(t, []string{staticToken, jitToken}, capturedTokens) + assert.Equal(t, 1, requester.callCount) +} + +func TestGitHubAPIHandler_DoesNotFallbackWithoutStaticCredentials(t *testing.T) { + credentials := config.Credentials{ + { + "type": "jit_access", + "credential-type": "git_source", + "host": "github.com", + "endpoint": "https://dependabot.example.com/jit_access", + }, + } + requester := &testGitHubAPIScopeRequester{} + handler := NewGitHubAPIHandler(credentials, requester) + + roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { + t.Fatal("request without proxy-added auth should not be retried") + return nil, nil + }) + req := httptest.NewRequest("GET", "https://api.github.com/repos/example/internal-repo/releases", nil) + ctx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + rsp := &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("initial"))} + + _ = handleRequestAndClose(handler, req, ctx) + newRsp := handler.HandleResponse(rsp, ctx) + defer newRsp.Body.Close() + + assert.Equal(t, 404, newRsp.StatusCode) + assert.Equal(t, 0, requester.callCount) +} + +func TestGitHubAPIHandler_FailedJITAccessIsNotRetriedForRepository(t *testing.T) { + credentials := config.Credentials{ + testGitSourceCred("github.com", "x-access-token", "ghp_static"), + { + "type": "jit_access", + "credential-type": "git_source", + "host": "github.com", + "endpoint": "https://dependabot.example.com/jit_access", + }, + } + requester := &testGitHubAPIScopeRequester{err: errors.New("JIT access unavailable")} + handler := NewGitHubAPIHandler(credentials, requester) + roundTripCount := 0 + roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { + roundTripCount++ + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("not found"))}, nil + }) + + for range 2 { + req := httptest.NewRequest("GET", "https://api.github.com/repos/example/internal-repo/releases", nil) + ctx := &goproxy.ProxyCtx{Req: req, RoundTripper: roundTripper} + rsp := &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("initial"))} + _ = handleRequestAndClose(handler, req, ctx) + newRsp := handler.HandleResponse(rsp, ctx) + newRsp.Body.Close() + } + + assert.Equal(t, 1, requester.callCount) + assert.Equal(t, 1, roundTripCount) +} + +func TestGitHubAPIHandler_JITAccessTokenIsCached(t *testing.T) { + staticToken := "ghp_static" + jitToken := "ghp_jit" + credentials := config.Credentials{ + testGitSourceCred("github.com", "x-access-token", staticToken), + { + "type": "jit_access", + "credential-type": "git_source", + "host": "github.com", + "endpoint": "https://dependabot.example.com/jit_access", + }, + } + requester := &testGitHubAPIScopeRequester{ + result: &config.Credential{ + "username": "x-access-token", + "password": jitToken, + }, + } + handler := NewGitHubAPIHandler(credentials, requester) + + roundTripper := goproxy.RoundTripperFunc(func(r *http.Request, c *goproxy.ProxyCtx) (*http.Response, error) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") + if token == jitToken { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("jit-ok"))}, nil + } + return &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader("denied"))}, nil + }) + + req1 := httptest.NewRequest("GET", "https://api.github.com/repos/dependabot/proxy", nil) + ctx1 := &goproxy.ProxyCtx{Req: req1, RoundTripper: roundTripper} + rsp1 := &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader("initial"))} + _ = handleRequestAndClose(handler, req1, ctx1) + newRsp1 := handler.HandleResponse(rsp1, ctx1) + defer newRsp1.Body.Close() + assert.Equal(t, 200, newRsp1.StatusCode) + assert.Equal(t, 1, requester.callCount) + + req2 := httptest.NewRequest("GET", "https://api.github.com/repos/dependabot/proxy", nil) + ctx2 := &goproxy.ProxyCtx{Req: req2, RoundTripper: roundTripper} + rsp2 := &http.Response{StatusCode: 401, Body: io.NopCloser(strings.NewReader("initial"))} + _ = handleRequestAndClose(handler, req2, ctx2) + newRsp2 := handler.HandleResponse(rsp2, ctx2) + defer newRsp2.Body.Close() + assert.Equal(t, 200, newRsp2.StatusCode) + + // Cached repo-scoped credentials should be retried before requesting JIT again. + assert.Equal(t, 1, requester.callCount) +} diff --git a/proxy.go b/proxy.go index 16f1bef..f2aa018 100644 --- a/proxy.go +++ b/proxy.go @@ -26,6 +26,10 @@ type Proxy struct { Close func() error } +func githubAPIJITAccessEnabled() bool { + return os.Getenv("PROXY_GITHUB_API_JIT_ACCESS") == "true" +} + func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIps []net.IP) *Proxy { var err error @@ -77,7 +81,11 @@ func newProxy(envSettings config.ProxyEnvSettings, cfg *config.Config, blockedIp proxy.OnRequest().DoFunc(metricsHandler.HandleRequest) proxy.OnResponse().DoFunc(metricsHandler.HandleResponse) - gitHubAPIHandler := handlers.NewGitHubAPIHandler(cfg.Credentials) + var gitHubAPIJITClient handlers.ScopeRequester + if githubAPIJITAccessEnabled() { + gitHubAPIJITClient = apiClient + } + gitHubAPIHandler := handlers.NewGitHubAPIHandler(cfg.Credentials, gitHubAPIJITClient) proxy.OnRequest().DoFunc(gitHubAPIHandler.HandleRequest) proxy.OnResponse().DoFunc(gitHubAPIHandler.HandleResponse) diff --git a/proxy_test.go b/proxy_test.go index ce0c049..3ac0116 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -29,6 +29,25 @@ var ( } ) +func TestGitHubAPIJITAccessEnabled(t *testing.T) { + tests := []struct { + value string + enabled bool + }{ + {value: "", enabled: false}, + {value: "false", enabled: false}, + {value: "TRUE", enabled: false}, + {value: "true", enabled: true}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + t.Setenv("PROXY_GITHUB_API_JIT_ACCESS", test.value) + assert.Equal(t, test.enabled, githubAPIJITAccessEnabled()) + }) + } +} + func TestProxyHTTPRequest(t *testing.T) { var blockedIPs []net.IP client, proxy := testProxyServer(t, testProxyConfig, blockedIPs)