diff --git a/docs/pages/licenses/devpod.mdx b/docs/pages/licenses/devpod.mdx index 9cd8fb4f2..a03bcf275 100644 --- a/docs/pages/licenses/devpod.mdx +++ b/docs/pages/licenses/devpod.mdx @@ -68,7 +68,6 @@ Some packages may only be included on certain architectures or operating systems - [github.com/charmbracelet/x/ansi](https://pkg.go.dev/github.com/charmbracelet/x/ansi) ([MIT](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/charmbracelet/x/ansi/LICENSE)) - [github.com/charmbracelet/x/exp/strings](https://pkg.go.dev/github.com/charmbracelet/x/exp/strings) ([MIT](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/charmbracelet/x/exp/strings/LICENSE)) - [github.com/charmbracelet/x/term](https://pkg.go.dev/github.com/charmbracelet/x/term) ([MIT](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/charmbracelet/x/term/LICENSE)) - - [github.com/chrismellard/docker-credential-acr-env/pkg](https://pkg.go.dev/github.com/chrismellard/docker-credential-acr-env/pkg) ([Apache-2.0](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/chrismellard/docker-credential-acr-env/LICENSE)) - [github.com/compose-spec/compose-go/v2](https://pkg.go.dev/github.com/compose-spec/compose-go/v2) ([Apache-2.0](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/compose-spec/compose-go/v2/LICENSE)) - [github.com/compose-spec/compose-go/v2/dotenv](https://pkg.go.dev/github.com/compose-spec/compose-go/v2/dotenv) ([MIT](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/compose-spec/compose-go/v2/dotenv/LICENSE)) - [github.com/containerd/console](https://pkg.go.dev/github.com/containerd/console) ([Apache-2.0](https://github.com/skevetter/devpod/blob/HEAD/vendor/github.com/containerd/console/LICENSE)) diff --git a/go.mod b/go.mod index 7e7cff577..0ad0784ec 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.25.7 require ( al.essio.dev/pkg/shellescape v1.6.0 + github.com/Azure/go-autorest/autorest/adal v0.9.23 + github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 github.com/Microsoft/go-winio v0.6.2 github.com/PaesslerAG/jsonpath v0.1.1 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d @@ -11,7 +13,6 @@ require ( github.com/blang/semver/v4 v4.0.0 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/huh v0.8.0 - github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 github.com/compose-spec/compose-go/v2 v2.10.1 github.com/containers/image/v5 v5.36.2 github.com/creack/pty v1.1.24 @@ -90,12 +91,9 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect - github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest v0.11.29 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect - github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect diff --git a/go.sum b/go.sum index fd5d37988..fcddd8653 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -168,8 +166,6 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= -github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= @@ -608,7 +604,6 @@ github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22 github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= -github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= diff --git a/pkg/image/acr.go b/pkg/image/acr.go new file mode 100644 index 000000000..d57f6fb79 --- /dev/null +++ b/pkg/image/acr.go @@ -0,0 +1,213 @@ +package image + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" + + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure/auth" + "github.com/docker/docker-credential-helpers/credentials" +) + +var acrRE = regexp.MustCompile(`.*\.azurecr\.io|.*\.azurecr\.cn|.*\.azurecr\.de|.*\.azurecr\.us`) + +const ( + mcrHostname = "mcr.microsoft.com" + tokenUsername = "" + acrTimeout = 30 * time.Second +) + +type acrCredHelper struct{} + +func newACRCredentialsHelper() credentials.Helper { + return &acrCredHelper{} +} + +func (a *acrCredHelper) Add(_ *credentials.Credentials) error { + return errors.New("add is unimplemented") +} + +func (a *acrCredHelper) Delete(_ string) error { + return errors.New("delete is unimplemented") +} + +func (a *acrCredHelper) List() (map[string]string, error) { + return nil, errors.New("list is unimplemented") +} + +func (a *acrCredHelper) Get(serverURL string) (string, string, error) { + if !isACRRegistry(serverURL) { + return "", "", errors.New("serverURL does not refer to Azure Container Registry") + } + + spToken, settings, err := getServicePrincipalToken() + if err != nil { + return "", "", fmt.Errorf("failed to acquire sp token: %w", err) + } + + refreshToken, err := exchangeForACRRefreshToken( + serverURL, spToken, settings.Values[auth.TenantID], + ) + if err != nil { + return "", "", fmt.Errorf("failed to acquire refresh token: %w", err) + } + + return tokenUsername, refreshToken, nil +} + +func isACRRegistry(input string) bool { + serverURL, err := url.Parse("https://" + input) + if err != nil { + return false + } + if serverURL.Hostname() == mcrHostname { + return true + } + return acrRE.MatchString(serverURL.Hostname()) +} + +func getServicePrincipalToken() ( + *adal.ServicePrincipalToken, auth.EnvironmentSettings, error, +) { + settings, err := auth.GetSettingsFromEnvironment() + if err != nil { + return nil, auth.EnvironmentSettings{}, fmt.Errorf( + "failed to get auth settings from environment: %w", err, + ) + } + + spToken, err := newServicePrincipalToken( + settings, settings.Environment.ResourceManagerEndpoint, + ) + if err != nil { + return nil, auth.EnvironmentSettings{}, fmt.Errorf( + "failed to initialise sp token config: %w", err, + ) + } + + return spToken, settings, nil +} + +func newServicePrincipalToken( + settings auth.EnvironmentSettings, resource string, +) (*adal.ServicePrincipalToken, error) { + // 1. Client Credentials + if cc, err := settings.GetClientCredentials(); err == nil { + oAuthConfig, oauthErr := adal.NewOAuthConfig( + settings.Environment.ActiveDirectoryEndpoint, cc.TenantID, + ) + if oauthErr != nil { + return nil, fmt.Errorf("failed to initialise OAuthConfig: %w", oauthErr) + } + return adal.NewServicePrincipalToken( + *oAuthConfig, cc.ClientID, cc.ClientSecret, resource, + ) + } + + // 2. Federated OIDC JWT assertion + if jwt, jwtErr := lookupFederatedJWT(); jwtErr == nil { + clientID := os.Getenv("AZURE_CLIENT_ID") + if clientID == "" { + return nil, fmt.Errorf("AZURE_CLIENT_ID not set") + } + tenantID := os.Getenv("AZURE_TENANT_ID") + if tenantID == "" { + return nil, fmt.Errorf("AZURE_TENANT_ID not set") + } + oAuthConfig, oauthErr := adal.NewOAuthConfig( + settings.Environment.ActiveDirectoryEndpoint, tenantID, + ) + if oauthErr != nil { + return nil, fmt.Errorf("failed to initialise OAuthConfig: %w", oauthErr) + } + return adal.NewServicePrincipalTokenFromFederatedTokenCallback( + *oAuthConfig, clientID, + func() (string, error) { return jwt, nil }, + resource, + ) + } + + // 3. MSI + return adal.NewServicePrincipalTokenFromManagedIdentity( + resource, &adal.ManagedIdentityOptions{ + ClientID: os.Getenv("AZURE_CLIENT_ID"), + }, + ) +} + +func lookupFederatedJWT() (string, error) { + if jwt, ok := os.LookupEnv("AZURE_FEDERATED_TOKEN"); ok { + return jwt, nil + } + if jwtFile, ok := os.LookupEnv("AZURE_FEDERATED_TOKEN_FILE"); ok { + b, err := os.ReadFile(jwtFile) //nolint:gosec // path from trusted env var + if err != nil { + return "", err + } + return string(b), nil + } + return "", fmt.Errorf("no federated JWT found") +} + +func exchangeForACRRefreshToken( + serverURL string, + principalToken *adal.ServicePrincipalToken, + tenantID string, +) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), acrTimeout) + defer cancel() + + principalToken.MaxMSIRefreshAttempts = 1 + if err := principalToken.EnsureFreshWithContext(ctx); err != nil { + return "", fmt.Errorf("error refreshing sp token: %w", err) + } + + registryURL := "https://" + serverURL + exchangeURL := registryURL + "/oauth2/exchange" + + form := url.Values{ + "grant_type": {"access_token"}, + "service": {serverURL}, + "tenant": {tenantID}, + "access_token": {principalToken.Token().AccessToken}, + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, exchangeURL, strings.NewReader(form.Encode()), + ) + if err != nil { + return "", fmt.Errorf("failed to create exchange request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to exchange token: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token exchange returned status %d", resp.StatusCode) + } + + var result struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode exchange response: %w", err) + } + + if result.RefreshToken == "" { + return "", fmt.Errorf("exchange returned empty refresh token") + } + + return result.RefreshToken, nil +} diff --git a/pkg/image/auth.go b/pkg/image/auth.go index b7f868ac1..56a3b6d7f 100644 --- a/pkg/image/auth.go +++ b/pkg/image/auth.go @@ -9,7 +9,6 @@ import ( "strings" ecr "github.com/awslabs/amazon-ecr-credential-helper/ecr-login" - "github.com/chrismellard/docker-credential-acr-env/pkg/credhelper" "github.com/golang-jwt/jwt/v5" "github.com/google/go-containerregistry/pkg/authn" kubernetesauth "github.com/google/go-containerregistry/pkg/authn/kubernetes" @@ -21,7 +20,7 @@ var ( ecr.NewECRHelper(ecr.WithLogger(io.Discard)), ) azureKeychain authn.Keychain = authn.NewKeychainFromHelper( - credhelper.NewACRCredentialsHelper(), + newACRCredentialsHelper(), ) )