diff --git a/download/oci_download.go b/download/oci_download.go index de9153f40f..b90a22ea40 100644 --- a/download/oci_download.go +++ b/download/oci_download.go @@ -331,9 +331,7 @@ func dockerResolver(plugin rest.HTTPAuthPlugin, config *rest.Config, logger logg authorizer := pluginAuthorizer{ plugin: plugin, - authorizer: docker.NewDockerAuthorizer( - docker.WithAuthClient(client), - ), + client: client, logger: logger, } @@ -356,7 +354,11 @@ func dockerResolver(plugin rest.HTTPAuthPlugin, config *rest.Config, logger logg } type pluginAuthorizer struct { - plugin rest.HTTPAuthPlugin + plugin rest.HTTPAuthPlugin + client *http.Client + + // authorizer will be populated by the first call to pluginAuthorizer.Prepare + // since it requires a first pass through the plugin.Prepare method. authorizer docker.Authorizer logger logging.Logger @@ -380,6 +382,25 @@ func (a *pluginAuthorizer) Authorize(ctx context.Context, req *http.Request) err return err } + if a.authorizer == nil { + // Some registry authentication implementations require a token fetch from + // a separate authenticated token server. This flow is described in the + // docker token auth spec: + // https://docs.docker.com/registry/spec/auth/token/#requesting-a-token + // + // Unfortunately, the containerd implementation does not use the Prepare + // mechanism to authenticate these token requests and we need to add + // auth information in form of a static docker.WithAuthHeader. + // + // Since rest.HTTPAuthPlugins will set the auth header on the request + // passed to HTTPAuthPlugin.Prepare, we can use it afterwards to build + // our docker.Authorizer. + a.authorizer = docker.NewDockerAuthorizer( + docker.WithAuthHeader(req.Header), + docker.WithAuthClient(a.client), + ) + } + return a.authorizer.Authorize(ctx, req) } diff --git a/download/oci_download_test.go b/download/oci_download_test.go index e316a986aa..1eb17f0265 100644 --- a/download/oci_download_test.go +++ b/download/oci_download_test.go @@ -186,6 +186,45 @@ func TestOCIPublicRegistryAuth(t *testing.T) { } } +// TestOCITokenAuth tests the registry `token` auth that is used for some registries (f.e. gitlab). +// After the initial fetch the token has to be added to the request that fetches the temporary token. +// This test verifies that the token is added to the second token request. +func TestOCITokenAuth(t *testing.T) { + ctx := context.Background() + fixture := newTestFixture(t, withAuthenticatedTokenAuth()) + plainToken := "secret" + token := base64.StdEncoding.EncodeToString([]byte(plainToken)) // token should be base64 encoded + fixture.server.expAuth = fmt.Sprintf("Bearer %s", token) // test on private repository + fixture.server.expEtag = "sha256:c5834dbce332cabe6ae68a364de171a50bf5b08024c27d7c08cc72878b4df7ff" + + restConf := fmt.Sprintf(`{ + "url": %q, + "type": "oci", + "credentials": { + "bearer": { + "token": %q + } + } + }`, fixture.server.server.URL, plainToken) + + client, err := rest.New([]byte(restConf), map[string]*keys.Config{}) + if err != nil { + t.Fatalf("failed to create rest client: %s", err) + } + fixture.setClient(client) + + config := Config{} + if err := config.ValidateAndInjectDefaults(); err != nil { + t.Fatal(err) + } + + d := NewOCI(Config{}, fixture.client, "ghcr.io/org/repo:latest", t.TempDir()) + + if err := d.oneShot(ctx); err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + func TestOCICustomAuthPlugin(t *testing.T) { fixture := newTestFixture(t) defer fixture.server.stop() diff --git a/download/testharness.go b/download/testharness.go index 0fce0f52a4..c0d39324b2 100644 --- a/download/testharness.go +++ b/download/testharness.go @@ -112,13 +112,70 @@ func withPublicRegistryAuth() fixtureOpt { } } +// withAuthenticatedTokenAuth sets up a token auth flow according to +// the spec https://docs.docker.com/registry/spec/auth/token/. +// +// The flow is the same as for public registries but additionally +// the request for fetching the token also has to be authenticated. +// Used for example with gitlab registries. +// +// The token issuing and validation differs between providers, +// and we only use a minimal version for testing. +func withAuthenticatedTokenAuth() fixtureOpt { + const token = "some-test-token" + tokenServer := httptest.NewServer(tokenHandlerAuth("c2VjcmV0", token)) + + const wwwAuthenticateFmt = "Bearer realm=%q service=%q scope=%q" + tokenServiceURL := tokenServer.URL + "/token" + wwwAuthenticate := fmt.Sprintf(wwwAuthenticateFmt, + tokenServiceURL, + "testRegistry.io", + "[pull]") + + return func(tf *testFixture) error { + tf.server.customAuth = func(w http.ResponseWriter, r *http.Request) error { + + authHeader := r.Header.Get("Authorization") + + if authHeader == "" { + w.Header().Set("WWW-Authenticate", wwwAuthenticate) + return fmt.Errorf("no authorization header: %w", errUnauthorized) + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + w.Header().Set("WWW-Authenticate", wwwAuthenticate) + return fmt.Errorf("expects bearer scheme: %w", errUnauthorized) + } + + bearerToken := strings.TrimPrefix(authHeader, "Bearer ") + if bearerToken != token { + w.Header().Set("WWW-Authenticate", wwwAuthenticate) + return fmt.Errorf("token %q doesn't match %q: %w", bearerToken, token, errUnauthorized) + } + + return nil + } + + return nil + } +} + // tokenHandler returns an http.Handler that responds with the // specified token to GET /token requests. -func tokenHandler(token string) http.HandlerFunc { +func tokenHandler(issuedToken string) http.HandlerFunc { + return tokenHandlerAuth("", issuedToken) +} + +// tokenHandlerAuth returns an http.Handler that responds with the +// specified token to GET /token requests. +// +// If expectedToken is not empty, the handler will check that the +// Authorization header matches the expected token. +func tokenHandlerAuth(expectedToken, issuedToken string) http.HandlerFunc { tokenResponse := struct { Token string `json:"token"` }{ - Token: token, + Token: issuedToken, } responseBody, err := json.Marshal(tokenResponse) @@ -137,7 +194,30 @@ func tokenHandler(token string) http.HandlerFunc { return } - w.Write(responseBody) + // If no expected token is set, we don't check the Authorization header. + if expectedToken == "" { + _, _ = w.Write(responseBody) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + return + } + + bearerToken := strings.TrimPrefix(authHeader, "Bearer ") + if bearerToken != expectedToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + + _, _ = w.Write(responseBody) } }