mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
The route was registered GET-only, so containerd and Docker, which try the OAuth2 POST endpoint first whenever they hold a secret, ate a 405 and retried on the GET form. Every authenticated pull paid two auth round trips, and in the production logs the POST share of token traffic grew from 0.5% to 38% over six weeks as more clients pulled from k8s with basic-auth imagePullSecrets. Serve both specs on the same path. After credentials and scope are extracted the two paths are identical, so this is an extraction branch plus a form-shaped error writer. Only grant_type=password is supported and no refresh token is issued: the registry JWT's lifetime is pinned to the AppView<->hold service-auth, so a refresh token would be a fourth long-lived credential with its own storage and revocation. Clients handle its absence by continuing to use the credential they already hold. The refresh grant is refused with 401 rather than the 400 that RFC 6749 5.2 prescribes. containerd sends that grant only when it has no username, which is the same condition that disables its 405 fallback, so a 400 would hard-fail those clients. 401 is on its retry list and routes them to the GET form, where a device secret authenticates off the password alone. That shape previously had no working path at all. resolveService now takes the requested service as an argument, since it arrives in the query string on GET and in the form body on POST. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
package token
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeService(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"bare host", "atcr.io", "atcr.io"},
|
|
{"uppercase", "ATCR.io", "atcr.io"},
|
|
{"surrounding space", " atcr.io ", "atcr.io"},
|
|
{"host with port", "127.0.0.1:5000", "127.0.0.1"},
|
|
// The credential helper validates stored credentials against
|
|
// appViewURL + "/auth/token?service=" + appViewURL, so ?service=
|
|
// arrives as a full URL rather than a hostname.
|
|
{"https url", "https://atcr.io", "atcr.io"},
|
|
{"http url with port", "http://127.0.0.1:5000", "127.0.0.1"},
|
|
{"url with path", "https://atcr.io/auth/token", "atcr.io"},
|
|
{"url with query", "https://atcr.io/auth/token?service=x", "atcr.io"},
|
|
{"bracketed ipv6 with port", "[::1]:5000", "::1"},
|
|
{"bracketed ipv6", "[::1]", "::1"},
|
|
{"empty", "", ""},
|
|
{"only space", " ", ""},
|
|
{"scheme only", "https://", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := NormalizeService(tt.in); got != tt.want {
|
|
t.Errorf("NormalizeService(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerResolveService(t *testing.T) {
|
|
const primary = "buoy.cr"
|
|
services := []string{primary, "seamark.cr", "atcr.io"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
services []string
|
|
query string
|
|
host string
|
|
want string
|
|
description string
|
|
}{
|
|
{
|
|
name: "service param names a registry domain", services: services,
|
|
query: "atcr.io", host: "seamark.dev", want: "atcr.io",
|
|
description: "Docker echoes back the challenge's service; the realm lives on the UI host",
|
|
},
|
|
{
|
|
name: "service param as full url", services: services,
|
|
query: "https://atcr.io", host: "seamark.dev", want: "atcr.io",
|
|
description: "the credential helper sends the appview URL as ?service=",
|
|
},
|
|
{
|
|
name: "falls back to request host", services: services,
|
|
query: "", host: "atcr.io", want: "atcr.io",
|
|
description: "clients reaching /auth/token directly on a registry domain",
|
|
},
|
|
{
|
|
name: "unknown service param falls back to primary", services: services,
|
|
query: "evil.example", host: "seamark.dev", want: primary,
|
|
description: "the audience must never be caller-chosen",
|
|
},
|
|
{
|
|
name: "unknown service param does not beat a known host", services: services,
|
|
query: "evil.example", host: "atcr.io", want: "atcr.io",
|
|
},
|
|
{
|
|
name: "ui host is not a registry domain", services: services,
|
|
query: "", host: "seamark.dev", want: primary,
|
|
},
|
|
{
|
|
name: "no services configured", services: nil,
|
|
query: "atcr.io", host: "atcr.io", want: primary,
|
|
description: "single-domain deployments keep the issuer's service",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &Handler{issuer: &Issuer{service: primary}}
|
|
h.SetServices(tt.services)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
|
|
req.Host = tt.host
|
|
if tt.query != "" {
|
|
q := req.URL.Query()
|
|
q.Set("service", tt.query)
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
if got := h.resolveService(req, req.URL.Query().Get("service")); got != tt.want {
|
|
t.Errorf("resolveService() = %q, want %q (%s)", got, tt.want, tt.description)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerSetServicesNormalizes(t *testing.T) {
|
|
h := &Handler{issuer: &Issuer{service: "buoy.cr"}}
|
|
h.SetServices([]string{"ATCR.io", "127.0.0.1:5000", " ", "seamark.cr"})
|
|
|
|
for _, want := range []string{"atcr.io", "127.0.0.1", "seamark.cr"} {
|
|
if !h.services[want] {
|
|
t.Errorf("services missing %q, got %v", want, h.services)
|
|
}
|
|
}
|
|
if len(h.services) != 3 {
|
|
t.Errorf("services = %v, want 3 entries", h.services)
|
|
}
|
|
}
|