osbase/oci/registry: add package
This adds the registry package, which contains a client and server
implementation of the OCI Distribution spec.
Change-Id: I080bb1dbc511f8e6466ca370b090d459d2b730e8
Reviewed-on: https://review.monogon.dev/c/monogon/+/4086
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/osbase/oci/registry/auth.go b/osbase/oci/registry/auth.go
new file mode 100644
index 0000000..a1e98aa
--- /dev/null
+++ b/osbase/oci/registry/auth.go
@@ -0,0 +1,113 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/cenkalti/backoff/v4"
+)
+
+type tokenBody struct {
+ Token string `json:"token"`
+ AccessToken string `json:"access_token"`
+}
+
+// handleUnauthorized implements token authentication based on this
+// specification: https://distribution.github.io/distribution/spec/auth/token/
+//
+// The registry will return Unauthorized if a token is required and it is
+// missing or invalid (e.g. expired). We then need to ask the authorization
+// service for a token, and retry the original request with the new token.
+//
+// Some registries (e.g. Docker Hub and ghcr.io) require a token even for public
+// repositories. In this case, the authorization service returns tokens without
+// requiring any credentials.
+func (c *Client) handleUnauthorized(ctx context.Context, resp *http.Response) (retry bool, err error) {
+ // Check if we have a Bearer challenge.
+ challenges := parseAuthenticateHeader(resp.Header.Values("Www-Authenticate"))
+ var params map[string]string
+ for _, c := range challenges {
+ if strings.EqualFold(c.scheme, "bearer") {
+ params = c.params
+ break
+ }
+ }
+ realm := params["realm"]
+ if realm == "" {
+ // There is no challenge, return the original HTTP error.
+ return false, nil
+ }
+
+ // Construct token URL.
+ tokenURL, err := url.Parse(realm)
+ if err != nil {
+ return false, backoff.Permanent(fmt.Errorf("failed to parse realm: %w", err))
+ }
+ query := tokenURL.Query()
+ service := params["service"]
+ if service != "" {
+ query.Set("service", service)
+ }
+ for scope := range strings.SplitSeq(params["scope"], " ") {
+ if scope != "" {
+ query.Add("scope", scope)
+ }
+ }
+ tokenURL.RawQuery = query.Encode()
+
+ // Do token request.
+ req, err := http.NewRequestWithContext(ctx, "GET", tokenURL.String(), nil)
+ if err != nil {
+ return false, err
+ }
+ if c.UserAgent != "" {
+ req.Header.Set("User-Agent", c.UserAgent)
+ }
+ client := http.Client{Transport: c.Transport}
+ tokenResp, err := client.Do(req)
+ if err != nil {
+ return false, redactURLError(err)
+ }
+ if tokenResp.StatusCode != http.StatusOK {
+ return false, readClientError(tokenResp, req)
+ }
+ defer tokenResp.Body.Close()
+
+ // Parse token response.
+ bodyBytes, err := readFullBody(tokenResp, 1024*1024)
+ if err != nil {
+ return false, err
+ }
+ body := tokenBody{}
+ err = json.Unmarshal(bodyBytes, &body)
+ if err != nil {
+ return false, backoff.Permanent(fmt.Errorf("failed to parse token response: %w", err))
+ }
+ token := body.Token
+ if token == "" {
+ token = body.AccessToken
+ }
+ if token == "" {
+ return false, backoff.Permanent(fmt.Errorf("missing token in token response"))
+ }
+
+ c.authMu.Lock()
+ c.bearerToken = token
+ c.authMu.Unlock()
+ return true, nil
+}
+
+func (c *Client) addAuthorization(req *http.Request) {
+ c.authMu.RLock()
+ defer c.authMu.RUnlock()
+ if c.bearerToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ }
+}