Skip to content
Open
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
3 changes: 2 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ upstream:
cargo_download: "https://static.crates.io/crates"

# Authentication for upstream registries
# Keys are URL prefixes matched against request URLs.
# Keys are absolute URL scopes. Scheme, host, effective port, and path
# segment boundaries must match; the longest matching scope wins.
# Values can reference environment variables using ${VAR_NAME} syntax.
#
# Supported auth types:
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ Fetches artifacts from upstream registries.
- Exponential backoff retry on 429 (rate limit) and 5xx errors
- Returns streaming reader (doesn't load into memory)
- Configurable user-agent
- Shares an authentication-aware transport with metadata requests so URL-scoped credentials apply consistently
- Discovers and caches scoped OCI Bearer tokens from registry challenges

**Resolver:**
- Determines download URL for a package/version
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ upstream:

## Authentication

Configure authentication for private upstream registries. Auth is matched by URL prefix, and credentials can reference environment variables using `${VAR_NAME}` syntax.
Configure authentication for private upstream registries. The same authentication-aware client is used for metadata and artifact downloads, and credentials can reference environment variables using `${VAR_NAME}` syntax.

OCI registries that return a Bearer challenge from a `/v2/{repository}/…` endpoint are handled automatically. The proxy discovers the token realm from `WWW-Authenticate`, applies any configured credentials for the token URL, and reuses the scoped token until shortly before it expires.

### Bearer Token

Expand Down Expand Up @@ -172,7 +174,7 @@ upstream:

### URL Matching

Auth configs are matched by URL prefix. The longest matching prefix wins, so you can configure different credentials for different paths:
Auth keys must be absolute URLs. Matching compares the scheme, host, effective port, and path-segment prefix, preventing credentials for `registry.example.com` from being sent to a lookalike host such as `registry.example.com.evil.test`. The longest matching scope wins, so you can configure different credentials for different paths:

```yaml
upstream:
Expand Down
71 changes: 65 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,23 +274,29 @@ type UpstreamConfig struct {
CargoDownload string `json:"cargo_download" yaml:"cargo_download"`

// Auth configures authentication for upstream registries.
// Keys are URL prefixes that are matched against request URLs.
// Keys are absolute URL scopes matched by scheme, host, effective port,
// and path-segment prefix.
// Example: "https://npm.pkg.github.com" matches all requests to that host.
Auth map[string]AuthConfig `json:"auth" yaml:"auth"`
}

// AuthForURL returns the auth config that matches the given URL.
// Matches are based on URL prefix - the longest matching prefix wins.
// The longest matching URL scope wins.
func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
if u.Auth == nil {
return nil
}
target, err := parseAuthURL(url)
if err != nil {
return nil
}
Comment thread
andrew marked this conversation as resolved.

var bestMatch *AuthConfig
var bestLen int

for pattern, auth := range u.Auth {
if strings.HasPrefix(url, pattern) && len(pattern) > bestLen {
configured, err := parseAuthURL(pattern)
if err == nil && authURLMatches(configured, target) && len(pattern) > bestLen {
a := auth // copy to avoid loop variable capture
bestMatch = &a
bestLen = len(pattern)
Expand All @@ -300,6 +306,55 @@ func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
return bestMatch
}

// Validate checks upstream authentication URL scopes.
func (u *UpstreamConfig) Validate() error {
for pattern := range u.Auth {
if _, err := parseAuthURL(pattern); err != nil {
return fmt.Errorf("invalid upstream.auth URL %q: %w", pattern, err)
}
}
return nil
}

func parseAuthURL(value string) (*url.URL, error) {
parsed, err := url.Parse(value)
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" || parsed.Opaque != "" {
return nil, fmt.Errorf("invalid authentication URL")
}
return parsed, nil
}

func authURLMatches(configured, target *url.URL) bool {
if !strings.EqualFold(configured.Scheme, target.Scheme) ||
!strings.EqualFold(configured.Hostname(), target.Hostname()) ||
authURLPort(configured) != authURLPort(target) {
return false
}
if configured.RawQuery != "" && configured.RawQuery != target.RawQuery {
return false
}

configuredPath := strings.TrimSuffix(configured.EscapedPath(), "/")
if configuredPath == "" {
return true
}
targetPath := strings.TrimSuffix(target.EscapedPath(), "/")
return targetPath == configuredPath || strings.HasPrefix(targetPath, configuredPath+"/")
}

func authURLPort(value *url.URL) string {
if port := value.Port(); port != "" {
return port
}
if strings.EqualFold(value.Scheme, "https") {
return "443"
}
if strings.EqualFold(value.Scheme, "http") {
return "80"
}
return ""
}

// AuthConfig configures authentication for an upstream registry.
type AuthConfig struct {
// Type is the authentication type: "bearer", "basic", or "header".
Expand Down Expand Up @@ -580,15 +635,19 @@ func (c *Config) Validate() error {
return err
}

if err := c.Health.Validate(); err != nil {
return c.validateComponents()
}

func (c *Config) validateComponents() error {
if err := c.Upstream.Validate(); err != nil {
return err
}

if err := c.Gradle.BuildCache.Validate(); err != nil {
if err := c.Health.Validate(); err != nil {
return err
}

return nil
return c.Gradle.BuildCache.Validate()
}

// Validate checks the /health configuration. An unset interval is allowed
Expand Down
71 changes: 71 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -760,3 +761,73 @@ func TestDatabaseConfigString(t *testing.T) {
}
}
}

func TestUpstreamAuthForURLMatchesURLComponents(t *testing.T) {
registryAuth := AuthConfig{Type: "bearer", Token: "registry-token"}
privateAuth := AuthConfig{Type: "bearer", Token: "private-token"}
config := UpstreamConfig{Auth: map[string]AuthConfig{
"https://registry.example.com": registryAuth,
"https://registry.example.com/private": privateAuth,
}}

tests := []struct {
name string
url string
wantToken string
}{
{name: "registry root", url: "https://registry.example.com/package", wantToken: "registry-token"},
{name: "host is case insensitive", url: "https://REGISTRY.EXAMPLE.COM/package", wantToken: "registry-token"},
{name: "longest path match", url: "https://registry.example.com/private/package", wantToken: "private-token"},
{name: "exact path match", url: "https://registry.example.com/private", wantToken: "private-token"},
{name: "path segment boundary", url: "https://registry.example.com/private-other/package", wantToken: "registry-token"},
{name: "lookalike host rejected", url: "https://registry.example.com.evil.test/package"},
{name: "different scheme rejected", url: "http://registry.example.com/package"},
{name: "different port rejected", url: "https://registry.example.com:8443/package"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
auth := config.AuthForURL(tt.url)
if tt.wantToken == "" {
if auth != nil {
t.Fatalf("AuthForURL() = %+v, want nil", auth)
}
return
}
if auth == nil {
t.Fatal("AuthForURL() = nil, want authentication")
}
if auth.Token != tt.wantToken {
t.Errorf("token = %q, want %q", auth.Token, tt.wantToken)
}
})
}
}

func TestValidateUpstreamAuthURLs(t *testing.T) {
t.Run("valid absolute URL", func(t *testing.T) {
cfg := Default()
cfg.Upstream.Auth = map[string]AuthConfig{
"https://registry.example.com/private": {Type: "bearer", Token: "token"},
}

if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
})

t.Run("invalid URL", func(t *testing.T) {
cfg := Default()
cfg.Upstream.Auth = map[string]AuthConfig{
"registry.example.com": {Type: "bearer", Token: "token"},
}

err := cfg.Validate()
if err == nil {
t.Fatal("Validate() error = nil, want invalid upstream.auth URL error")
}
if !strings.Contains(err.Error(), "upstream.auth") || !strings.Contains(err.Error(), "registry.example.com") {
t.Errorf("Validate() error = %q, want field and URL", err)
}
})
}
Loading
Loading