Skip to content

Commit f8f475a

Browse files
committed
upstream: apply authentication through shared transport
1 parent 23364bd commit f8f475a

8 files changed

Lines changed: 689 additions & 16 deletions

File tree

config.example.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ upstream:
9696
cargo_download: "https://static.crates.io/crates"
9797

9898
# Authentication for upstream registries
99-
# Keys are URL prefixes matched against request URLs.
99+
# Keys are absolute URL scopes. Scheme, host, effective port, and path
100+
# segment boundaries must match; the longest matching scope wins.
100101
# Values can reference environment variables using ${VAR_NAME} syntax.
101102
#
102103
# Supported auth types:

docs/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ Fetches artifacts from upstream registries.
240240
- Exponential backoff retry on 429 (rate limit) and 5xx errors
241241
- Returns streaming reader (doesn't load into memory)
242242
- Configurable user-agent
243+
- Shares an authentication-aware transport with metadata requests so URL-scoped credentials apply consistently
244+
- Discovers and caches scoped OCI Bearer tokens from registry challenges
243245

244246
**Resolver:**
245247
- Determines download URL for a package/version

docs/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ upstream:
123123

124124
## Authentication
125125

126-
Configure authentication for private upstream registries. Auth is matched by URL prefix, and credentials can reference environment variables using `${VAR_NAME}` syntax.
126+
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.
127+
128+
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.
127129

128130
### Bearer Token
129131

@@ -172,7 +174,7 @@ upstream:
172174

173175
### URL Matching
174176

175-
Auth configs are matched by URL prefix. The longest matching prefix wins, so you can configure different credentials for different paths:
177+
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:
176178

177179
```yaml
178180
upstream:

internal/config/config.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,23 +274,29 @@ type UpstreamConfig struct {
274274
CargoDownload string `json:"cargo_download" yaml:"cargo_download"`
275275

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

282283
// AuthForURL returns the auth config that matches the given URL.
283-
// Matches are based on URL prefix - the longest matching prefix wins.
284+
// The longest matching URL scope wins.
284285
func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
285286
if u.Auth == nil {
286287
return nil
287288
}
289+
target, err := parseAuthURL(url)
290+
if err != nil {
291+
return nil
292+
}
288293

289294
var bestMatch *AuthConfig
290295
var bestLen int
291296

292297
for pattern, auth := range u.Auth {
293-
if strings.HasPrefix(url, pattern) && len(pattern) > bestLen {
298+
configured, err := parseAuthURL(pattern)
299+
if err == nil && authURLMatches(configured, target) && len(pattern) > bestLen {
294300
a := auth // copy to avoid loop variable capture
295301
bestMatch = &a
296302
bestLen = len(pattern)
@@ -300,6 +306,45 @@ func (u *UpstreamConfig) AuthForURL(url string) *AuthConfig {
300306
return bestMatch
301307
}
302308

309+
func parseAuthURL(value string) (*url.URL, error) {
310+
parsed, err := url.Parse(value)
311+
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" || parsed.Opaque != "" {
312+
return nil, fmt.Errorf("invalid authentication URL")
313+
}
314+
return parsed, nil
315+
}
316+
317+
func authURLMatches(configured, target *url.URL) bool {
318+
if !strings.EqualFold(configured.Scheme, target.Scheme) ||
319+
!strings.EqualFold(configured.Hostname(), target.Hostname()) ||
320+
authURLPort(configured) != authURLPort(target) {
321+
return false
322+
}
323+
if configured.RawQuery != "" && configured.RawQuery != target.RawQuery {
324+
return false
325+
}
326+
327+
configuredPath := strings.TrimSuffix(configured.EscapedPath(), "/")
328+
if configuredPath == "" {
329+
return true
330+
}
331+
targetPath := strings.TrimSuffix(target.EscapedPath(), "/")
332+
return targetPath == configuredPath || strings.HasPrefix(targetPath, configuredPath+"/")
333+
}
334+
335+
func authURLPort(value *url.URL) string {
336+
if port := value.Port(); port != "" {
337+
return port
338+
}
339+
if strings.EqualFold(value.Scheme, "https") {
340+
return "443"
341+
}
342+
if strings.EqualFold(value.Scheme, "http") {
343+
return "80"
344+
}
345+
return ""
346+
}
347+
303348
// AuthConfig configures authentication for an upstream registry.
304349
type AuthConfig struct {
305350
// Type is the authentication type: "bearer", "basic", or "header".

internal/config/config_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,3 +760,45 @@ func TestDatabaseConfigString(t *testing.T) {
760760
}
761761
}
762762
}
763+
764+
func TestUpstreamAuthForURLMatchesURLComponents(t *testing.T) {
765+
registryAuth := AuthConfig{Type: "bearer", Token: "registry-token"}
766+
privateAuth := AuthConfig{Type: "bearer", Token: "private-token"}
767+
config := UpstreamConfig{Auth: map[string]AuthConfig{
768+
"https://registry.example.com": registryAuth,
769+
"https://registry.example.com/private": privateAuth,
770+
}}
771+
772+
tests := []struct {
773+
name string
774+
url string
775+
wantToken string
776+
}{
777+
{name: "registry root", url: "https://registry.example.com/package", wantToken: "registry-token"},
778+
{name: "host is case insensitive", url: "https://REGISTRY.EXAMPLE.COM/package", wantToken: "registry-token"},
779+
{name: "longest path match", url: "https://registry.example.com/private/package", wantToken: "private-token"},
780+
{name: "exact path match", url: "https://registry.example.com/private", wantToken: "private-token"},
781+
{name: "path segment boundary", url: "https://registry.example.com/private-other/package", wantToken: "registry-token"},
782+
{name: "lookalike host rejected", url: "https://registry.example.com.evil.test/package"},
783+
{name: "different scheme rejected", url: "http://registry.example.com/package"},
784+
{name: "different port rejected", url: "https://registry.example.com:8443/package"},
785+
}
786+
787+
for _, tt := range tests {
788+
t.Run(tt.name, func(t *testing.T) {
789+
auth := config.AuthForURL(tt.url)
790+
if tt.wantToken == "" {
791+
if auth != nil {
792+
t.Fatalf("AuthForURL() = %+v, want nil", auth)
793+
}
794+
return
795+
}
796+
if auth == nil {
797+
t.Fatal("AuthForURL() = nil, want authentication")
798+
}
799+
if auth.Token != tt.wantToken {
800+
t.Errorf("token = %q, want %q", auth.Token, tt.wantToken)
801+
}
802+
})
803+
}
804+
}

0 commit comments

Comments
 (0)