diff --git a/internal/handlers/hex_organization.go b/internal/handlers/hex_organization.go index 95045a2..74a8d18 100644 --- a/internal/handlers/hex_organization.go +++ b/internal/handlers/hex_organization.go @@ -13,12 +13,17 @@ import ( // HexOrganizationHandler handles requests to repo.hex.pm, adding auth. type HexOrganizationHandler struct { - orgTokens map[string]string + credentials []hexOrganizationCredentials +} + +type hexOrganizationCredentials struct { + organization string + key string } // NewHexOrganizationHandler returns a new HexOrganizationHandler. func NewHexOrganizationHandler(creds config.Credentials) *HexOrganizationHandler { - handler := HexOrganizationHandler{orgTokens: map[string]string{}} + handler := HexOrganizationHandler{credentials: []hexOrganizationCredentials{}} for _, cred := range creds { if cred["type"] != "hex_organization" { @@ -26,12 +31,20 @@ func NewHexOrganizationHandler(creds config.Credentials) *HexOrganizationHandler } org := cred.GetString("organization") - token := cred.GetString("token") - if org == "" || token == "" { + // Support both "key" and "token" (backwards compatibility) + key := cred.GetString("key") + if key == "" { + key = cred.GetString("token") + } + if org == "" || key == "" { continue } - handler.orgTokens[org] = token + hexCred := hexOrganizationCredentials{ + organization: org, + key: key, + } + handler.credentials = append(handler.credentials, hexCred) } return &handler @@ -52,13 +65,14 @@ func (h *HexOrganizationHandler) HandleRequest(req *http.Request, ctx *goproxy.P return req, nil } - token, ok := h.orgTokens[pathParts[1]] - if !ok { - return req, nil + reqOrg := pathParts[1] + for _, cred := range h.credentials { + if cred.organization == reqOrg { + logging.RequestLogf(ctx, "* authenticating hex request (org: %s)", reqOrg) + req.Header.Set("authorization", cred.key) + return req, nil + } } - logging.RequestLogf(ctx, "* authenticating hex request (org: %s)", pathParts[1]) - req.Header.Set("authorization", token) - return req, nil } diff --git a/internal/handlers/hex_organization_test.go b/internal/handlers/hex_organization_test.go index 84eaea9..0fc49bd 100644 --- a/internal/handlers/hex_organization_test.go +++ b/internal/handlers/hex_organization_test.go @@ -8,29 +8,29 @@ import ( ) func TestHexOrganizationHandler(t *testing.T) { - dependabotToken := "123" - deltaForceToken := "456" + dependabotKey := "123" + deltaForceKey := "456" credentials := config.Credentials{ config.Credential{ "type": "hex_organization", "organization": "dependabot", - "token": dependabotToken, + "key": dependabotKey, }, config.Credential{ "type": "hex_organization", "organization": "deltaforce", - "token": deltaForceToken, + "key": deltaForceKey, }, } handler := NewHexOrganizationHandler(credentials) req := httptest.NewRequest("GET", "https://repo.hex.pm/repos/dependabot/packages/foo", nil) req = handleRequestAndClose(handler, req, nil) - assertHasTokenAuth(t, req, "", dependabotToken, "dependabot registry request") + assertHasTokenAuth(t, req, "", dependabotKey, "dependabot registry request") req = httptest.NewRequest("GET", "https://repo.hex.pm/repos/deltaforce/packages/foo", nil) req = handleRequestAndClose(handler, req, nil) - assertHasTokenAuth(t, req, "", deltaForceToken, "deltaforce registry request") + assertHasTokenAuth(t, req, "", deltaForceKey, "deltaforce registry request") // Not an org req = httptest.NewRequest("GET", "https://repo.hex.pm/packages/foo", nil) @@ -52,3 +52,36 @@ func TestHexOrganizationHandler(t *testing.T) { req = handleRequestAndClose(handler, req, nil) assertUnauthenticated(t, req, "post request") } + +func TestHexOrganizationHandler_BackwardsCompatibility(t *testing.T) { + t.Run("supports legacy token field", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{ + "type": "hex_organization", + "organization": "legacy-org", + "token": "legacy-token", + }, + } + handler := NewHexOrganizationHandler(credentials) + + req := httptest.NewRequest("GET", "https://repo.hex.pm/repos/legacy-org/packages/foo", nil) + req = handleRequestAndClose(handler, req, nil) + assertHasTokenAuth(t, req, "", "legacy-token", "should support legacy token field") + }) + + t.Run("key takes precedence over token", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{ + "type": "hex_organization", + "organization": "test-org", + "key": "new-key", + "token": "old-token", + }, + } + handler := NewHexOrganizationHandler(credentials) + + req := httptest.NewRequest("GET", "https://repo.hex.pm/repos/test-org/packages/foo", nil) + req = handleRequestAndClose(handler, req, nil) + assertHasTokenAuth(t, req, "", "new-key", "key should take precedence over token") + }) +} diff --git a/internal/handlers/terraform_registry.go b/internal/handlers/terraform_registry.go index 078f5ae..6ea639e 100644 --- a/internal/handlers/terraform_registry.go +++ b/internal/handlers/terraform_registry.go @@ -2,6 +2,8 @@ package handlers import ( "net/http" + "sort" + "strings" "sync" "github.com/elazarl/goproxy" @@ -13,14 +15,20 @@ import ( ) type TerraformRegistryHandler struct { - credentials map[string]string + credentials []terraformRegistryCredentials oidcCredentials map[string]*oidc.OIDCCredential mutex sync.RWMutex } +type terraformRegistryCredentials struct { + host string + url string + token string +} + func NewTerraformRegistryHandler(credentials config.Credentials) *TerraformRegistryHandler { handler := TerraformRegistryHandler{ - credentials: make(map[string]string), + credentials: []terraformRegistryCredentials{}, oidcCredentials: make(map[string]*oidc.OIDCCredential), } @@ -40,8 +48,33 @@ func NewTerraformRegistryHandler(credentials config.Credentials) *TerraformRegis continue } - handler.credentials[host] = credential.GetString("token") + token := credential.GetString("token") + url := credential.GetString("url") + + // Skip credentials with empty token or both empty host and url + if token == "" || (host == "" && url == "") { + continue + } + + terraformCred := terraformRegistryCredentials{ + url: url, + token: token, + } + // Only set host when url is not provided to ensure URL-prefix matching + // takes precedence and doesn't fall back to host matching + if url == "" { + terraformCred.host = host + } + handler.credentials = append(handler.credentials, terraformCred) } + + // Sort credentials by URL length descending (longest first) to ensure + // more specific URLs match before shorter ones. Using SliceStable for + // deterministic ordering when URL lengths are equal. + sort.SliceStable(handler.credentials, func(i, j int) bool { + return len(handler.credentials[i].url) > len(handler.credentials[j].url) + }) + return &handler } @@ -56,15 +89,65 @@ func (h *TerraformRegistryHandler) HandleRequest(request *http.Request, context } // Fall back to static credentials - host := request.URL.Hostname() - token, ok := h.credentials[host] + for _, cred := range h.credentials { + if !urlMatchesRequestWithBoundary(request, cred.url) && !helpers.CheckHost(request, cred.host) { + continue + } - if !ok { + logging.RequestLogf(context, "* authenticating terraform registry request (host: %s)", request.URL.Hostname()) + request.Header.Set("Authorization", "Bearer "+cred.token) return request, nil } - logging.RequestLogf(context, "* authenticating terraform registry request (host: %s)", host) - request.Header.Set("Authorization", "Bearer "+token) - return request, nil } + +// urlMatchesRequestWithBoundary checks if the request URL matches the credential URL +// with proper path boundary checking. +func urlMatchesRequestWithBoundary(request *http.Request, credURL string) bool { + if credURL == "" { + return false + } + + parsedURL, err := helpers.ParseURLLax(credURL) + if err != nil { + return false + } + + if !helpers.AreHostnamesEqual(parsedURL.Hostname(), request.URL.Hostname()) { + return false + } + + urlPort := parsedURL.Port() + if urlPort == "" { + urlPort = "443" + } + + reqPort := request.URL.Port() + if reqPort == "" { + reqPort = "443" + } + + if urlPort != reqPort { + return false + } + + credPath := strings.TrimRight(parsedURL.Path, "/") + reqPath := request.URL.Path + + if credPath == "" { + // Empty path matches everything on the host + return true + } + + if reqPath == credPath { + return true + } + + // Check if request path starts with credPath followed by / + if strings.HasPrefix(reqPath, credPath+"/") { + return true + } + + return false +} diff --git a/internal/handlers/terraform_registry_test.go b/internal/handlers/terraform_registry_test.go index 960427b..e7b00c9 100644 --- a/internal/handlers/terraform_registry_test.go +++ b/internal/handlers/terraform_registry_test.go @@ -74,4 +74,62 @@ func TestTerraformRegistryHandler(t *testing.T) { assert.Equal(t, "", request.Header.Get("Authorization"), "should be empty") }) + + t.Run("multiple credentials on same host with different URL paths", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "terraform_registry", "url": "https://terraform.example.com/org1", "token": "token-org1"}, + config.Credential{"type": "terraform_registry", "url": "https://terraform.example.com/org2", "token": "token-org2"}, + } + handler := NewTerraformRegistryHandler(credentials) + + // Request to org1 path should use org1 token + req1 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org1/v1/providers/foo", nil), nil) + assert.Equal(t, "Bearer token-org1", req1.Header.Get("Authorization"), "should use org1 token") + + // Request to org2 path should use org2 token + req2 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org2/v1/providers/bar", nil), nil) + assert.Equal(t, "Bearer token-org2", req2.Header.Get("Authorization"), "should use org2 token") + + // Request to unmatched path should not be authenticated + req3 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org3/v1/providers/baz", nil), nil) + assert.Equal(t, "", req3.Header.Get("Authorization"), "should not be authenticated") + }) + + t.Run("skips credentials with empty token", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "terraform_registry", "host": "terraform.example.org", "token": ""}, + } + handler := NewTerraformRegistryHandler(credentials) + assert.Equal(t, 0, len(handler.credentials), "should skip credential with empty token") + }) + + t.Run("skips credentials with empty host and url", func(t *testing.T) { + credentials := config.Credentials{ + config.Credential{"type": "terraform_registry", "token": "some-token"}, + } + handler := NewTerraformRegistryHandler(credentials) + assert.Equal(t, 0, len(handler.credentials), "should skip credential with empty host and url") + }) + + t.Run("path boundary: /org should not match /org1", func(t *testing.T) { + // Credentials are sorted longest-path-first to ensure /org1 matches before /org + credentials := config.Credentials{ + config.Credential{"type": "terraform_registry", "url": "https://terraform.example.com/org", "token": "token-org"}, + config.Credential{"type": "terraform_registry", "url": "https://terraform.example.com/org1", "token": "token-org1"}, + } + handler := NewTerraformRegistryHandler(credentials) + + assert.Equal(t, "https://terraform.example.com/org1", handler.credentials[0].url, "longer path should be first") + assert.Equal(t, "https://terraform.example.com/org", handler.credentials[1].url, "shorter path should be second") + + req1 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org1/v1/providers/foo", nil), nil) + assert.Equal(t, "Bearer token-org1", req1.Header.Get("Authorization"), "/org1 path should use org1 token") + + req2 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org/v1/providers/bar", nil), nil) + assert.Equal(t, "Bearer token-org", req2.Header.Get("Authorization"), "/org path should use org token") + + // Request to /org123 should NOT match /org1 or /org (path boundary check) + req3 := handleRequestAndClose(handler, httptest.NewRequest("GET", "https://terraform.example.com/org123/v1/providers/baz", nil), nil) + assert.Equal(t, "", req3.Header.Get("Authorization"), "/org123 should not match /org or /org1") + }) }