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
169 changes: 104 additions & 65 deletions pkg/registry/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ var (
errFailedExecuteChallengeRequest = errors.New("failed to execute challenge request")
// errFailedCreateBearerRequest indicates a failure to construct the HTTP request for a bearer token.
errFailedCreateBearerRequest = errors.New("failed to create bearer token request")
// errFailedConstructBearerAuthURL indicates a failure to construct the bearer authentication URL.
errFailedConstructBearerAuthURL = errors.New("failed to construct bearer auth url")
// errFailedExecuteBearerRequest indicates a failure to send or receive a response for the bearer token request.
errFailedExecuteBearerRequest = errors.New("failed to execute bearer token request")
// errFailedUnmarshalBearerResponse indicates a failure to parse the bearer token response JSON.
Expand Down Expand Up @@ -265,44 +267,31 @@ func handleBearerAuth(
) (string, string, bool, string, error) {
logrus.WithFields(fields).Debug("Entering Bearer auth path")

var challengeHost string

// Parse the WWW-Authenticate header.
scope, realm, service, err := ProcessChallenge(wwwAuthHeader, container.ImageName())
logrus.WithFields(fields).
WithField("realm", realm).
WithField("service", service).
WithField("scope", scope).
WithField("err", err).
Debug("Processed challenge header")

switch {
case err != nil:
logrus.WithError(err).WithFields(fields).Debug("Failed to process challenge header")
// Proceed with token retrieval, as challengeHost is optional.
case realm != "":
challengeHost = extractChallengeHost(realm, fields)
if challengeHost != "" {
logrus.WithFields(fields).
WithField("challenge_host", challengeHost).
Debug("Extracted challenge host")
}
default:
logrus.WithFields(fields).Debug("Empty realm in challenge header")
}

// Fetch the bearer token.
normalizedRef, err := reference.ParseNormalizedNamed(container.ImageName())
if err != nil {
logrus.WithError(err).WithFields(fields).Debug("Failed to parse image name")

return "", "", redirected, redirectHost, fmt.Errorf("%w: %w", errFailedParseImageName, err)
}

token, err := GetBearerHeader(
authURL, err := GetAuthURL(strings.ToLower(wwwAuthHeader), normalizedRef)
if err != nil {
logrus.WithError(err).WithFields(fields).Debug("Failed to construct bearer auth URL")

return "", "", redirected, redirectHost, fmt.Errorf("%w: %w", errFailedConstructBearerAuthURL, err)
}

challengeHost := authURL.Host
if challengeHost != "" {
logrus.WithFields(fields).
WithField("challenge_host", challengeHost).
Debug("Extracted challenge host")
}

token, err := getBearerHeader(
ctx,
strings.ToLower(wwwAuthHeader),
normalizedRef,
authURL,
container.ImageName(),
registryAuth,
client,
)
Expand Down Expand Up @@ -489,6 +478,7 @@ func GetToken(
// ProcessChallenge parses the WWW-Authenticate header to extract authentication details.
//
// It supports Bearer authentication, extracting the realm, service, and optional scope for token requests.
// If a registry omits service, the service is derived from the realm host.
//
// Parameters:
// - wwwAuthHeader: The WWW-Authenticate header value (e.g., 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:linuxserver/nginx:pull"').
Expand All @@ -498,7 +488,7 @@ func GetToken(
// - string: The scope for the token request (e.g., "repository:linuxserver/nginx:pull"), or empty if not provided.
// - string: The realm URL for the token request (e.g., "https://ghcr.io/token").
// - string: The service identifier (e.g., "ghcr.io").
// - error: Non-nil if parsing fails critically (missing realm or service), nil otherwise.
// - error: Non-nil if parsing fails critically (missing realm or derivable service), nil otherwise.
func ProcessChallenge(wwwAuthHeader, image string) (string, string, string, error) {
fields := logrus.Fields{
"image": image,
Expand All @@ -524,11 +514,20 @@ func ProcessChallenge(wwwAuthHeader, image string) (string, string, string, erro
}
}

realm, realmOK := values["realm"]
service, serviceOK := values["service"]
realm := values["realm"]
service := values["service"]
scope := values["scope"] // Scope is optional

if !realmOK || !serviceOK {
if service == "" && realm != "" {
service = extractChallengeHost(realm, fields)
if service != "" {
logrus.WithFields(fields).
WithField("service", service).
Debug("Derived challenge service from realm")
}
}

if realm == "" || service == "" {
logrus.WithFields(fields).Warn("Missing required challenge header values: realm or service")

return "", "", "", fmt.Errorf(
Expand Down Expand Up @@ -610,70 +609,97 @@ func GetBearerHeader(
return "", err
}

// Build the token request with context.
return getBearerHeader(ctx, authURL, imageRef.Name(), registryAuth, client)
}

func getBearerHeader(
ctx context.Context,
authURL *url.URL,
imageName string,
registryAuth string,
client Client,
) (string, error) {
r, err := newBearerRequest(ctx, authURL, imageName)
if err != nil {
return "", err
}

addBasicAuth(r, imageName, registryAuth)
logrus.WithField("url", r.URL.String()).Debug("Sending bearer token request")

authResponse, err := client.Do(r)
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"image": imageName,
"url": authURL.String(),
}).Debug("Failed to execute bearer token request")

return "", fmt.Errorf("%w: %w", errFailedExecuteBearerRequest, err)
}

defer authResponse.Body.Close()

token, err := readBearerToken(authResponse.Body, imageName)
if err != nil {
return "", err
}

logrus.WithFields(logrus.Fields{
"image": imageName,
}).Debug("Retrieved bearer token")

return "Bearer " + token, nil
}

func newBearerRequest(ctx context.Context, authURL *url.URL, imageName string) (*http.Request, error) {
r, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL.String(), nil)
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"image": imageRef.Name(),
"image": imageName,
"url": authURL.String(),
}).Debug("Failed to create bearer token request")

return "", fmt.Errorf("%w: %w", errFailedCreateBearerRequest, err)
return nil, fmt.Errorf("%w: %w", errFailedCreateBearerRequest, err)
}

// Add Basic auth header if credentials are provided.
return r, nil
}

func addBasicAuth(r *http.Request, imageName, registryAuth string) {
if registryAuth != "" {
logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
"image": imageName,
}).Debug("Found credentials")

if logrus.GetLevel() == logrus.TraceLevel {
logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
"image": imageName,
"registryAuth": registryAuth,
}).Trace("Using credentials")
}

r.Header.Add("Authorization", "Basic "+registryAuth)
} else {
logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
"image": imageName,
}).Debug("No credentials found")
}
}

// Execute the token request.
logrus.WithField("url", r.URL.String()).Debug("Sending bearer token request")

authResponse, err := client.Do(r)
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{
"image": imageRef.Name(),
"url": authURL.String(),
}).Debug("Failed to execute bearer token request")

return "", fmt.Errorf("%w: %w", errFailedExecuteBearerRequest, err)
}

defer authResponse.Body.Close()

// Read and parse the response body into a token structure.
body, _ := io.ReadAll(authResponse.Body)
func readBearerToken(body io.Reader, imageName string) (string, error) {
b, _ := io.ReadAll(body)
tokenResponse := &types.TokenResponse{}

err = json.Unmarshal(body, tokenResponse)
err := json.Unmarshal(b, tokenResponse)
if err != nil {
logrus.WithError(err).
WithField("image", imageRef.Name()).
WithField("image", imageName).
Debug("Failed to unmarshal bearer token response")

return "", fmt.Errorf("%w: %w", errFailedUnmarshalBearerResponse, err)
}

logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
}).Debug("Retrieved bearer token")

return "Bearer " + tokenResponse.Token, nil
return tokenResponse.Token, nil
}

// GetAuthURL constructs an authentication URL from challenge instructions.
Expand Down Expand Up @@ -726,6 +752,19 @@ func GetAuthURL(challenge string, imageRef reference.Named) (*url.URL, error) {
values["realm"] = "https://ghcr.io/token"
}

if values["service"] == "" && values["realm"] != "" {
values["service"] = extractChallengeHost(values["realm"], logrus.Fields{
"image": imageRef.Name(),
"challenge": challenge,
})
if values["service"] != "" {
logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
"service": values["service"],
}).Debug("Derived challenge service from realm")
}
}

logrus.WithFields(logrus.Fields{
"image": imageRef.Name(),
"realm": values["realm"],
Expand Down
117 changes: 114 additions & 3 deletions pkg/registry/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,60 @@ var _ = ginkgo.Describe("the auth module", func() {
gomega.Expect(server.ReceivedRequests()).To(gomega.HaveLen(2))
})

ginkgo.It("should derive bearer service from realm when registry challenge omits service", func() {
defer ginkgo.GinkgoRecover()

server := ghttp.NewTLSServer()
server.RouteToHandler("GET", "/v2/", ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", "/v2/"),
ghttp.RespondWith(
http.StatusUnauthorized,
"",
http.Header{
"WWW-Authenticate": []string{
fmt.Sprintf(`Bearer realm="https://%s/token"`, server.Addr()),
},
},
),
))

server.RouteToHandler("GET", "/token", ghttp.CombineHandlers(
ghttp.VerifyRequest("GET", "/token", "scope=repository%3Atest%2Fimage%3Apull&service="+url.QueryEscape(server.Addr())),
ghttp.RespondWith(http.StatusOK, `{"token": "mock-token"}`),
))
defer server.Close()

containerInstance := mockContainer{
id: mockID,
name: mockName,
imageName: server.Addr() + "/test/image:latest",
}
client := &testAuthClient{
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
}

viper.Set("WATCHTOWER_REGISTRY_TLS_SKIP", false)
defer viper.Set("WATCHTOWER_REGISTRY_TLS_SKIP", false)

token, challengeHost, redirected, redirectHost, err := auth.GetToken(
context.Background(),
containerInstance,
"",
client,
"",
)

gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(token).To(gomega.Equal("Bearer mock-token"))
gomega.Expect(challengeHost).To(gomega.Equal(server.Addr()))
gomega.Expect(redirected).To(gomega.BeFalse())
gomega.Expect(redirectHost).To(gomega.Equal(""))
gomega.Expect(server.ReceivedRequests()).To(gomega.HaveLen(2))
})
// Test case: Verifies that GetToken returns redirect=true when the challenge request is redirected.
ginkgo.It("should return redirect=true when challenge request is redirected", func() {
defer ginkgo.GinkgoRecover()
Expand Down Expand Up @@ -1354,10 +1408,8 @@ var _ = ginkgo.Describe("the auth module", func() {
)

ginkgo.When("given an invalid challenge header", func() {
// Test case: Verifies GetAuthURL returns an error when the challenge header lacks
// required fields (e.g., service). Ensures robust error handling for malformed inputs.
ginkgo.It("should return an error", func() {
challenge := `bearer realm="https://ghcr.io/token"`
challenge := `bearer service="ghcr.io"`
imageRef, err := reference.ParseNormalizedNamed("nicholas-fedor/watchtower")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
URL, err := auth.GetAuthURL(challenge, imageRef)
Expand All @@ -1366,6 +1418,65 @@ var _ = ginkgo.Describe("the auth module", func() {
})
})

ginkgo.It("should derive service from realm when challenge omits service", func() {
challenge := `bearer realm="https://registry.example.com/token"`
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
gomega.Expect(err).NotTo(gomega.HaveOccurred())

URL, err := auth.GetAuthURL(challenge, imageRef)

gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(URL.String()).
To(gomega.Equal("https://registry.example.com/token?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
})

ginkgo.It("should derive service from realm when service is explicitly empty", func() {
challenge := `bearer realm="https://registry.example.com/token",service=""`
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
gomega.Expect(err).NotTo(gomega.HaveOccurred())

URL, err := auth.GetAuthURL(challenge, imageRef)

gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(URL.String()).
To(gomega.Equal("https://registry.example.com/token?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
})

ginkgo.It("should derive service from realm host when realm includes a port", func() {
challenge := `bearer realm="http://localhost:5000/token"`
imageRef, err := reference.ParseNormalizedNamed("localhost:5000/test/image:latest")
gomega.Expect(err).NotTo(gomega.HaveOccurred())

URL, err := auth.GetAuthURL(challenge, imageRef)

gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(URL.String()).
To(gomega.Equal("http://localhost:5000/token?scope=repository%3Atest%2Fimage%3Apull&service=localhost%3A5000"))
})

ginkgo.It("should derive service from realm host when realm has a trailing slash", func() {
challenge := `bearer realm="https://registry.example.com/token/"`
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
gomega.Expect(err).NotTo(gomega.HaveOccurred())

URL, err := auth.GetAuthURL(challenge, imageRef)

gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(URL.String()).
To(gomega.Equal("https://registry.example.com/token/?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
})

ginkgo.It("should return an error when realm lacks a scheme and service is omitted", func() {
challenge := `bearer realm="registry.example.com/token"`
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
gomega.Expect(err).NotTo(gomega.HaveOccurred())

URL, err := auth.GetAuthURL(challenge, imageRef)

gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(URL).To(gomega.BeNil())
})

ginkgo.When("deriving the auth scope from an image name", func() {
// Test case: Ensures GetAuthURL prepends "library/" to official Docker Hub images,
// validating correct scope derivation for standard images.
Expand Down