Skip to content

Commit 528c332

Browse files
authored
fix(registry): derive bearer service from realm (#1895)
- Derive missing service from realm host in ProcessChallenge and GetAuthURL - Refactor handleBearerAuth to construct auth URL through GetAuthURL - Split GetBearerHeader into smaller internal helpers - Add regression and edge case tests for service derivation
1 parent a9d255a commit 528c332

2 files changed

Lines changed: 218 additions & 68 deletions

File tree

pkg/registry/auth/auth.go

Lines changed: 104 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ var (
6666
errFailedExecuteChallengeRequest = errors.New("failed to execute challenge request")
6767
// errFailedCreateBearerRequest indicates a failure to construct the HTTP request for a bearer token.
6868
errFailedCreateBearerRequest = errors.New("failed to create bearer token request")
69+
// errFailedConstructBearerAuthURL indicates a failure to construct the bearer authentication URL.
70+
errFailedConstructBearerAuthURL = errors.New("failed to construct bearer auth url")
6971
// errFailedExecuteBearerRequest indicates a failure to send or receive a response for the bearer token request.
7072
errFailedExecuteBearerRequest = errors.New("failed to execute bearer token request")
7173
// errFailedUnmarshalBearerResponse indicates a failure to parse the bearer token response JSON.
@@ -265,44 +267,31 @@ func handleBearerAuth(
265267
) (string, string, bool, string, error) {
266268
logrus.WithFields(fields).Debug("Entering Bearer auth path")
267269

268-
var challengeHost string
269-
270-
// Parse the WWW-Authenticate header.
271-
scope, realm, service, err := ProcessChallenge(wwwAuthHeader, container.ImageName())
272-
logrus.WithFields(fields).
273-
WithField("realm", realm).
274-
WithField("service", service).
275-
WithField("scope", scope).
276-
WithField("err", err).
277-
Debug("Processed challenge header")
278-
279-
switch {
280-
case err != nil:
281-
logrus.WithError(err).WithFields(fields).Debug("Failed to process challenge header")
282-
// Proceed with token retrieval, as challengeHost is optional.
283-
case realm != "":
284-
challengeHost = extractChallengeHost(realm, fields)
285-
if challengeHost != "" {
286-
logrus.WithFields(fields).
287-
WithField("challenge_host", challengeHost).
288-
Debug("Extracted challenge host")
289-
}
290-
default:
291-
logrus.WithFields(fields).Debug("Empty realm in challenge header")
292-
}
293-
294-
// Fetch the bearer token.
295270
normalizedRef, err := reference.ParseNormalizedNamed(container.ImageName())
296271
if err != nil {
297272
logrus.WithError(err).WithFields(fields).Debug("Failed to parse image name")
298273

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

302-
token, err := GetBearerHeader(
277+
authURL, err := GetAuthURL(strings.ToLower(wwwAuthHeader), normalizedRef)
278+
if err != nil {
279+
logrus.WithError(err).WithFields(fields).Debug("Failed to construct bearer auth URL")
280+
281+
return "", "", redirected, redirectHost, fmt.Errorf("%w: %w", errFailedConstructBearerAuthURL, err)
282+
}
283+
284+
challengeHost := authURL.Host
285+
if challengeHost != "" {
286+
logrus.WithFields(fields).
287+
WithField("challenge_host", challengeHost).
288+
Debug("Extracted challenge host")
289+
}
290+
291+
token, err := getBearerHeader(
303292
ctx,
304-
strings.ToLower(wwwAuthHeader),
305-
normalizedRef,
293+
authURL,
294+
container.ImageName(),
306295
registryAuth,
307296
client,
308297
)
@@ -489,6 +478,7 @@ func GetToken(
489478
// ProcessChallenge parses the WWW-Authenticate header to extract authentication details.
490479
//
491480
// It supports Bearer authentication, extracting the realm, service, and optional scope for token requests.
481+
// If a registry omits service, the service is derived from the realm host.
492482
//
493483
// Parameters:
494484
// - wwwAuthHeader: The WWW-Authenticate header value (e.g., 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:linuxserver/nginx:pull"').
@@ -498,7 +488,7 @@ func GetToken(
498488
// - string: The scope for the token request (e.g., "repository:linuxserver/nginx:pull"), or empty if not provided.
499489
// - string: The realm URL for the token request (e.g., "https://ghcr.io/token").
500490
// - string: The service identifier (e.g., "ghcr.io").
501-
// - error: Non-nil if parsing fails critically (missing realm or service), nil otherwise.
491+
// - error: Non-nil if parsing fails critically (missing realm or derivable service), nil otherwise.
502492
func ProcessChallenge(wwwAuthHeader, image string) (string, string, string, error) {
503493
fields := logrus.Fields{
504494
"image": image,
@@ -524,11 +514,20 @@ func ProcessChallenge(wwwAuthHeader, image string) (string, string, string, erro
524514
}
525515
}
526516

527-
realm, realmOK := values["realm"]
528-
service, serviceOK := values["service"]
517+
realm := values["realm"]
518+
service := values["service"]
529519
scope := values["scope"] // Scope is optional
530520

531-
if !realmOK || !serviceOK {
521+
if service == "" && realm != "" {
522+
service = extractChallengeHost(realm, fields)
523+
if service != "" {
524+
logrus.WithFields(fields).
525+
WithField("service", service).
526+
Debug("Derived challenge service from realm")
527+
}
528+
}
529+
530+
if realm == "" || service == "" {
532531
logrus.WithFields(fields).Warn("Missing required challenge header values: realm or service")
533532

534533
return "", "", "", fmt.Errorf(
@@ -610,70 +609,97 @@ func GetBearerHeader(
610609
return "", err
611610
}
612611

613-
// Build the token request with context.
612+
return getBearerHeader(ctx, authURL, imageRef.Name(), registryAuth, client)
613+
}
614+
615+
func getBearerHeader(
616+
ctx context.Context,
617+
authURL *url.URL,
618+
imageName string,
619+
registryAuth string,
620+
client Client,
621+
) (string, error) {
622+
r, err := newBearerRequest(ctx, authURL, imageName)
623+
if err != nil {
624+
return "", err
625+
}
626+
627+
addBasicAuth(r, imageName, registryAuth)
628+
logrus.WithField("url", r.URL.String()).Debug("Sending bearer token request")
629+
630+
authResponse, err := client.Do(r)
631+
if err != nil {
632+
logrus.WithError(err).WithFields(logrus.Fields{
633+
"image": imageName,
634+
"url": authURL.String(),
635+
}).Debug("Failed to execute bearer token request")
636+
637+
return "", fmt.Errorf("%w: %w", errFailedExecuteBearerRequest, err)
638+
}
639+
640+
defer authResponse.Body.Close()
641+
642+
token, err := readBearerToken(authResponse.Body, imageName)
643+
if err != nil {
644+
return "", err
645+
}
646+
647+
logrus.WithFields(logrus.Fields{
648+
"image": imageName,
649+
}).Debug("Retrieved bearer token")
650+
651+
return "Bearer " + token, nil
652+
}
653+
654+
func newBearerRequest(ctx context.Context, authURL *url.URL, imageName string) (*http.Request, error) {
614655
r, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL.String(), nil)
615656
if err != nil {
616657
logrus.WithError(err).WithFields(logrus.Fields{
617-
"image": imageRef.Name(),
658+
"image": imageName,
618659
"url": authURL.String(),
619660
}).Debug("Failed to create bearer token request")
620661

621-
return "", fmt.Errorf("%w: %w", errFailedCreateBearerRequest, err)
662+
return nil, fmt.Errorf("%w: %w", errFailedCreateBearerRequest, err)
622663
}
623664

624-
// Add Basic auth header if credentials are provided.
665+
return r, nil
666+
}
667+
668+
func addBasicAuth(r *http.Request, imageName, registryAuth string) {
625669
if registryAuth != "" {
626670
logrus.WithFields(logrus.Fields{
627-
"image": imageRef.Name(),
671+
"image": imageName,
628672
}).Debug("Found credentials")
629673

630674
if logrus.GetLevel() == logrus.TraceLevel {
631675
logrus.WithFields(logrus.Fields{
632-
"image": imageRef.Name(),
676+
"image": imageName,
633677
"registryAuth": registryAuth,
634678
}).Trace("Using credentials")
635679
}
636680

637681
r.Header.Add("Authorization", "Basic "+registryAuth)
638682
} else {
639683
logrus.WithFields(logrus.Fields{
640-
"image": imageRef.Name(),
684+
"image": imageName,
641685
}).Debug("No credentials found")
642686
}
687+
}
643688

644-
// Execute the token request.
645-
logrus.WithField("url", r.URL.String()).Debug("Sending bearer token request")
646-
647-
authResponse, err := client.Do(r)
648-
if err != nil {
649-
logrus.WithError(err).WithFields(logrus.Fields{
650-
"image": imageRef.Name(),
651-
"url": authURL.String(),
652-
}).Debug("Failed to execute bearer token request")
653-
654-
return "", fmt.Errorf("%w: %w", errFailedExecuteBearerRequest, err)
655-
}
656-
657-
defer authResponse.Body.Close()
658-
659-
// Read and parse the response body into a token structure.
660-
body, _ := io.ReadAll(authResponse.Body)
689+
func readBearerToken(body io.Reader, imageName string) (string, error) {
690+
b, _ := io.ReadAll(body)
661691
tokenResponse := &types.TokenResponse{}
662692

663-
err = json.Unmarshal(body, tokenResponse)
693+
err := json.Unmarshal(b, tokenResponse)
664694
if err != nil {
665695
logrus.WithError(err).
666-
WithField("image", imageRef.Name()).
696+
WithField("image", imageName).
667697
Debug("Failed to unmarshal bearer token response")
668698

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

672-
logrus.WithFields(logrus.Fields{
673-
"image": imageRef.Name(),
674-
}).Debug("Retrieved bearer token")
675-
676-
return "Bearer " + tokenResponse.Token, nil
702+
return tokenResponse.Token, nil
677703
}
678704

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

755+
if values["service"] == "" && values["realm"] != "" {
756+
values["service"] = extractChallengeHost(values["realm"], logrus.Fields{
757+
"image": imageRef.Name(),
758+
"challenge": challenge,
759+
})
760+
if values["service"] != "" {
761+
logrus.WithFields(logrus.Fields{
762+
"image": imageRef.Name(),
763+
"service": values["service"],
764+
}).Debug("Derived challenge service from realm")
765+
}
766+
}
767+
729768
logrus.WithFields(logrus.Fields{
730769
"image": imageRef.Name(),
731770
"realm": values["realm"],

pkg/registry/auth/auth_test.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,60 @@ var _ = ginkgo.Describe("the auth module", func() {
991991
gomega.Expect(server.ReceivedRequests()).To(gomega.HaveLen(2))
992992
})
993993

994+
ginkgo.It("should derive bearer service from realm when registry challenge omits service", func() {
995+
defer ginkgo.GinkgoRecover()
996+
997+
server := ghttp.NewTLSServer()
998+
server.RouteToHandler("GET", "/v2/", ghttp.CombineHandlers(
999+
ghttp.VerifyRequest("GET", "/v2/"),
1000+
ghttp.RespondWith(
1001+
http.StatusUnauthorized,
1002+
"",
1003+
http.Header{
1004+
"WWW-Authenticate": []string{
1005+
fmt.Sprintf(`Bearer realm="https://%s/token"`, server.Addr()),
1006+
},
1007+
},
1008+
),
1009+
))
1010+
1011+
server.RouteToHandler("GET", "/token", ghttp.CombineHandlers(
1012+
ghttp.VerifyRequest("GET", "/token", "scope=repository%3Atest%2Fimage%3Apull&service="+url.QueryEscape(server.Addr())),
1013+
ghttp.RespondWith(http.StatusOK, `{"token": "mock-token"}`),
1014+
))
1015+
defer server.Close()
1016+
1017+
containerInstance := mockContainer{
1018+
id: mockID,
1019+
name: mockName,
1020+
imageName: server.Addr() + "/test/image:latest",
1021+
}
1022+
client := &testAuthClient{
1023+
client: &http.Client{
1024+
Transport: &http.Transport{
1025+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
1026+
},
1027+
},
1028+
}
1029+
1030+
viper.Set("WATCHTOWER_REGISTRY_TLS_SKIP", false)
1031+
defer viper.Set("WATCHTOWER_REGISTRY_TLS_SKIP", false)
1032+
1033+
token, challengeHost, redirected, redirectHost, err := auth.GetToken(
1034+
context.Background(),
1035+
containerInstance,
1036+
"",
1037+
client,
1038+
"",
1039+
)
1040+
1041+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1042+
gomega.Expect(token).To(gomega.Equal("Bearer mock-token"))
1043+
gomega.Expect(challengeHost).To(gomega.Equal(server.Addr()))
1044+
gomega.Expect(redirected).To(gomega.BeFalse())
1045+
gomega.Expect(redirectHost).To(gomega.Equal(""))
1046+
gomega.Expect(server.ReceivedRequests()).To(gomega.HaveLen(2))
1047+
})
9941048
// Test case: Verifies that GetToken returns redirect=true when the challenge request is redirected.
9951049
ginkgo.It("should return redirect=true when challenge request is redirected", func() {
9961050
defer ginkgo.GinkgoRecover()
@@ -1354,10 +1408,8 @@ var _ = ginkgo.Describe("the auth module", func() {
13541408
)
13551409

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

1421+
ginkgo.It("should derive service from realm when challenge omits service", func() {
1422+
challenge := `bearer realm="https://registry.example.com/token"`
1423+
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
1424+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1425+
1426+
URL, err := auth.GetAuthURL(challenge, imageRef)
1427+
1428+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1429+
gomega.Expect(URL.String()).
1430+
To(gomega.Equal("https://registry.example.com/token?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
1431+
})
1432+
1433+
ginkgo.It("should derive service from realm when service is explicitly empty", func() {
1434+
challenge := `bearer realm="https://registry.example.com/token",service=""`
1435+
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
1436+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1437+
1438+
URL, err := auth.GetAuthURL(challenge, imageRef)
1439+
1440+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1441+
gomega.Expect(URL.String()).
1442+
To(gomega.Equal("https://registry.example.com/token?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
1443+
})
1444+
1445+
ginkgo.It("should derive service from realm host when realm includes a port", func() {
1446+
challenge := `bearer realm="http://localhost:5000/token"`
1447+
imageRef, err := reference.ParseNormalizedNamed("localhost:5000/test/image:latest")
1448+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1449+
1450+
URL, err := auth.GetAuthURL(challenge, imageRef)
1451+
1452+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1453+
gomega.Expect(URL.String()).
1454+
To(gomega.Equal("http://localhost:5000/token?scope=repository%3Atest%2Fimage%3Apull&service=localhost%3A5000"))
1455+
})
1456+
1457+
ginkgo.It("should derive service from realm host when realm has a trailing slash", func() {
1458+
challenge := `bearer realm="https://registry.example.com/token/"`
1459+
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
1460+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1461+
1462+
URL, err := auth.GetAuthURL(challenge, imageRef)
1463+
1464+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1465+
gomega.Expect(URL.String()).
1466+
To(gomega.Equal("https://registry.example.com/token/?scope=repository%3Atest%2Fimage%3Apull&service=registry.example.com"))
1467+
})
1468+
1469+
ginkgo.It("should return an error when realm lacks a scheme and service is omitted", func() {
1470+
challenge := `bearer realm="registry.example.com/token"`
1471+
imageRef, err := reference.ParseNormalizedNamed("registry.example.com/test/image:latest")
1472+
gomega.Expect(err).NotTo(gomega.HaveOccurred())
1473+
1474+
URL, err := auth.GetAuthURL(challenge, imageRef)
1475+
1476+
gomega.Expect(err).To(gomega.HaveOccurred())
1477+
gomega.Expect(URL).To(gomega.BeNil())
1478+
})
1479+
13691480
ginkgo.When("deriving the auth scope from an image name", func() {
13701481
// Test case: Ensures GetAuthURL prepends "library/" to official Docker Hub images,
13711482
// validating correct scope derivation for standard images.

0 commit comments

Comments
 (0)