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
36 changes: 25 additions & 11 deletions internal/handlers/hex_organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,38 @@ 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
}
Comment thread
AbhishekBhaskar marked this conversation as resolved.

// NewHexOrganizationHandler returns a new HexOrganizationHandler.
func NewHexOrganizationHandler(creds config.Credentials) *HexOrganizationHandler {
handler := HexOrganizationHandler{orgTokens: map[string]string{}}
handler := HexOrganizationHandler{credentials: []hexOrganizationCredentials{}}

Comment thread
AbhishekBhaskar marked this conversation as resolved.
for _, cred := range creds {
if cred["type"] != "hex_organization" {
continue
}

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")
}
Comment thread
AbhishekBhaskar marked this conversation as resolved.
if org == "" || key == "" {
continue
}

handler.orgTokens[org] = token
hexCred := hexOrganizationCredentials{
organization: org,
key: key,
}
handler.credentials = append(handler.credentials, hexCred)
}

return &handler
Expand All @@ -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 {
Comment thread
AbhishekBhaskar marked this conversation as resolved.
logging.RequestLogf(ctx, "* authenticating hex request (org: %s)", reqOrg)
req.Header.Set("authorization", cred.key)
Comment thread
AbhishekBhaskar marked this conversation as resolved.
return req, nil
}
Comment thread
AbhishekBhaskar marked this conversation as resolved.
}

logging.RequestLogf(ctx, "* authenticating hex request (org: %s)", pathParts[1])
req.Header.Set("authorization", token)

return req, nil
}
45 changes: 39 additions & 6 deletions internal/handlers/hex_organization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
})
}
101 changes: 92 additions & 9 deletions internal/handlers/terraform_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package handlers

import (
"net/http"
"sort"
"strings"
"sync"

"github.com/elazarl/goproxy"
Expand All @@ -13,14 +15,20 @@ import (
)

type TerraformRegistryHandler struct {
credentials map[string]string
credentials []terraformRegistryCredentials
oidcCredentials map[string]*oidc.OIDCCredential
mutex sync.RWMutex
Comment thread
AbhishekBhaskar marked this conversation as resolved.
}

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),
}

Expand All @@ -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)
}
Comment thread
kbukum1 marked this conversation as resolved.

// 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
}

Expand All @@ -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())
Comment thread
AbhishekBhaskar marked this conversation as resolved.
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
}
58 changes: 58 additions & 0 deletions internal/handlers/terraform_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading