Skip to content
Draft
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
1 change: 0 additions & 1 deletion docs/pages/licenses/devpod.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 2 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0
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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
213 changes: 213 additions & 0 deletions pkg/image/acr.go
Original file line number Diff line number Diff line change
@@ -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 = "<token>"
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
}
3 changes: 1 addition & 2 deletions pkg/image/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,7 +20,7 @@ var (
ecr.NewECRHelper(ecr.WithLogger(io.Discard)),
)
azureKeychain authn.Keychain = authn.NewKeychainFromHelper(
credhelper.NewACRCredentialsHelper(),
newACRCredentialsHelper(),
)
)

Expand Down
Loading