From 8a11af149fc7b64f178c76a6879811bb868d8c7e Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 19 Aug 2025 17:44:47 +0200 Subject: [PATCH] azuredns: pipeline credential support (#2621) --- docs/content/dns/zz_gen_azuredns.md | 4 + providers/dns/azuredns/azuredns.go | 129 ++++++------------------ providers/dns/azuredns/azuredns.toml | 4 + providers/dns/azuredns/credentials.go | 135 ++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 100 deletions(-) create mode 100644 providers/dns/azuredns/credentials.go diff --git a/docs/content/dns/zz_gen_azuredns.md b/docs/content/dns/zz_gen_azuredns.md index f9e7d3844..85feaae88 100644 --- a/docs/content/dns/zz_gen_azuredns.md +++ b/docs/content/dns/zz_gen_azuredns.md @@ -229,6 +229,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. +### Azure DevOps Pipelines + +It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. + diff --git a/providers/dns/azuredns/azuredns.go b/providers/dns/azuredns/azuredns.go index 860d19691..dcd4543b0 100644 --- a/providers/dns/azuredns/azuredns.go +++ b/providers/dns/azuredns/azuredns.go @@ -3,19 +3,13 @@ package azuredns import ( - "context" "errors" "fmt" "net/http" - "strings" "time" - "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" ) @@ -33,10 +27,21 @@ const ( EnvClientID = envNamespace + "CLIENT_ID" EnvClientSecret = envNamespace + "CLIENT_SECRET" - EnvOIDCToken = envNamespace + "OIDC_TOKEN" - EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" - EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" - EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" + EnvOIDCToken = envNamespace + "OIDC_TOKEN" + EnvOIDCTokenFilePath = envNamespace + "OIDC_TOKEN_FILE_PATH" + EnvOIDCRequestURL = envNamespace + "OIDC_REQUEST_URL" + EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" + altEnvArmOIDCRequestURL = "ARM_OIDC_REQUEST_URL" + EnvOIDCRequestToken = envNamespace + "OIDC_REQUEST_TOKEN" + EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" + altEnvArmOIDCRequestToken = "ARM_OIDC_REQUEST_TOKEN" + + EnvServiceConnectionID = envNamespace + "SERVICE_CONNECTION_ID" + altEnvServiceConnectionID = "SERVICE_CONNECTION_ID" + altEnvArmAdoPipelineServiceConnectionID = "ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID" + altEnvArmOIDCAzureServiceConnectionID = "ARM_OIDC_AZURE_SERVICE_CONNECTION_ID" + EnvSystemAccessToken = envNamespace + "SYSTEM_ACCESS_TOKEN" + altEnvSystemAccessToken = "SYSTEM_ACCESSTOKEN" EnvAuthMethod = envNamespace + "AUTH_METHOD" EnvAuthMSITimeout = envNamespace + "AUTH_MSI_TIMEOUT" @@ -46,9 +51,6 @@ const ( EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - - EnvGitHubOIDCRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" - EnvGitHubOIDCRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) @@ -73,6 +75,9 @@ type Config struct { OIDCRequestURL string OIDCRequestToken string + ServiceConnectionID string + SystemAccessToken string + AuthMethod string AuthMSITimeout time.Duration @@ -134,13 +139,22 @@ func NewDNSProvider() (*DNSProvider, error) { config.ServiceDiscoveryFilter = env.GetOrFile(EnvServiceDiscoveryFilter) oidcValues, _ := env.GetWithFallback( - []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL}, - []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken}, + []string{EnvOIDCRequestURL, EnvGitHubOIDCRequestURL, altEnvArmOIDCRequestURL}, + []string{EnvOIDCRequestToken, EnvGitHubOIDCRequestToken, altEnvArmOIDCRequestToken}, ) config.OIDCRequestURL = oidcValues[EnvOIDCRequestURL] config.OIDCRequestToken = oidcValues[EnvOIDCRequestToken] + // https://registry.terraform.io/providers/hashicorp/Azurerm/latest/docs/guides/service_principal_oidc + pipelineValues, _ := env.GetWithFallback( + []string{EnvServiceConnectionID, altEnvServiceConnectionID, altEnvArmAdoPipelineServiceConnectionID, altEnvArmOIDCAzureServiceConnectionID}, + []string{EnvSystemAccessToken, altEnvArmOIDCRequestToken, altEnvSystemAccessToken}, + ) + + config.ServiceConnectionID = pipelineValues[EnvServiceConnectionID] + config.SystemAccessToken = pipelineValues[EnvSystemAccessToken] + config.AuthMethod = env.GetOrFile(EnvAuthMethod) config.AuthMSITimeout = env.GetOrDefaultSecond(EnvAuthMSITimeout, 2*time.Second) @@ -193,88 +207,3 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return d.provider.CleanUp(domain, token, keyAuth) } - -func getCredentials(config *Config) (azcore.TokenCredential, error) { - clientOptions := azcore.ClientOptions{Cloud: config.Environment} - - switch strings.ToLower(config.AuthMethod) { - case "env": - if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { - return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, - &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) - } - - return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) - - case "wli": - return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) - - case "msi": - cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) - if err != nil { - return nil, err - } - - return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil - - case "cli": - var credOptions *azidentity.AzureCLICredentialOptions - if config.TenantID != "" { - credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID} - } - return azidentity.NewAzureCLICredential(credOptions) - - case "oidc": - err := checkOIDCConfig(config) - if err != nil { - return nil, err - } - - return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) - - default: - return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) - } -} - -// timeoutTokenCredential wraps a TokenCredential to add a timeout. -type timeoutTokenCredential struct { - cred azcore.TokenCredential - timeout time.Duration -} - -// GetToken implements the azcore.TokenCredential interface. -func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { - if w.timeout <= 0 { - return w.cred.GetToken(ctx, opts) - } - - ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) - defer cancel() - - tk, err := w.cred.GetToken(ctxTimeout, opts) - if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { - return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") - } - - w.timeout = 0 - - return tk, err -} - -func getZoneName(config *Config, fqdn string) (string, error) { - if config.ZoneName != "" { - return config.ZoneName, nil - } - - authZone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) - } - - if authZone == "" { - return "", errors.New("empty zone name") - } - - return authZone, nil -} diff --git a/providers/dns/azuredns/azuredns.toml b/providers/dns/azuredns/azuredns.toml index 8d14105cb..6c1e1ccff 100644 --- a/providers/dns/azuredns/azuredns.toml +++ b/providers/dns/azuredns/azuredns.toml @@ -174,6 +174,10 @@ This authentication method can be specifically used by setting the `AZURE_AUTH_M Open ID Connect is a mechanism that establish a trust relationship between a running environment and the Azure AD identity provider. It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `oidc`. +### Azure DevOps Pipelines + +It can be enabled by setting the `AZURE_AUTH_METHOD` environment variable to `pipeline`. + ''' [Configuration] diff --git a/providers/dns/azuredns/credentials.go b/providers/dns/azuredns/credentials.go new file mode 100644 index 000000000..efca10e59 --- /dev/null +++ b/providers/dns/azuredns/credentials.go @@ -0,0 +1,135 @@ +package azuredns + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/go-acme/lego/v4/challenge/dns01" +) + +const ( + authMethodEnv = "env" + authMethodWLI = "wli" + authMethodMSI = "msi" + authMethodCLI = "cli" + authMethodOIDC = "oidc" + authMethodPipeline = "pipeline" +) + +//nolint:gocyclo // The complexity is related to the number of possible configurations. +func getCredentials(config *Config) (azcore.TokenCredential, error) { + clientOptions := azcore.ClientOptions{Cloud: config.Environment} + + switch strings.ToLower(config.AuthMethod) { + case authMethodEnv: + if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { + return azidentity.NewClientSecretCredential(config.TenantID, config.ClientID, config.ClientSecret, + &azidentity.ClientSecretCredentialOptions{ClientOptions: clientOptions}) + } + + return azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{ClientOptions: clientOptions}) + + case authMethodWLI: + return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ClientOptions: clientOptions}) + + case authMethodMSI: + cred, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ClientOptions: clientOptions}) + if err != nil { + return nil, err + } + + return &timeoutTokenCredential{cred: cred, timeout: config.AuthMSITimeout}, nil + + case authMethodCLI: + var credOptions *azidentity.AzureCLICredentialOptions + if config.TenantID != "" { + credOptions = &azidentity.AzureCLICredentialOptions{TenantID: config.TenantID} + } + return azidentity.NewAzureCLICredential(credOptions) + + case authMethodOIDC: + err := checkOIDCConfig(config) + if err != nil { + return nil, err + } + + return azidentity.NewClientAssertionCredential(config.TenantID, config.ClientID, getOIDCAssertion(config), &azidentity.ClientAssertionCredentialOptions{ClientOptions: clientOptions}) + + case authMethodPipeline: + err := checkPipelineConfig(config) + if err != nil { + return nil, err + } + + // Uses the env var `SYSTEM_OIDCREQUESTURI`, + // but the constant is not exported, + // and there is no way to set it programmatically. + // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L22 + // https://github.com/Azure/azure-sdk-for-go/blob/aae2fb75ffccafc669db72bebc3c1a66332f48d7/sdk/azidentity/azure_pipelines_credential.go#L79 + + return azidentity.NewAzurePipelinesCredential(config.TenantID, config.ClientID, config.ServiceConnectionID, config.SystemAccessToken, &azidentity.AzurePipelinesCredentialOptions{ClientOptions: clientOptions}) + + default: + return azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ClientOptions: clientOptions}) + } +} + +// timeoutTokenCredential wraps a TokenCredential to add a timeout. +type timeoutTokenCredential struct { + cred azcore.TokenCredential + timeout time.Duration +} + +// GetToken implements the azcore.TokenCredential interface. +func (w *timeoutTokenCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + if w.timeout <= 0 { + return w.cred.GetToken(ctx, opts) + } + + ctxTimeout, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + + tk, err := w.cred.GetToken(ctxTimeout, opts) + if ce := ctxTimeout.Err(); errors.Is(ce, context.DeadlineExceeded) { + return tk, azidentity.NewCredentialUnavailableError("managed identity timed out") + } + + w.timeout = 0 + + return tk, err +} + +func getZoneName(config *Config, fqdn string) (string, error) { + if config.ZoneName != "" { + return config.ZoneName, nil + } + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) + } + + if authZone == "" { + return "", errors.New("empty zone name") + } + + return authZone, nil +} + +func checkPipelineConfig(config *Config) error { + if config.ServiceConnectionID == "" { + return errors.New("azuredns: ServiceConnectionID is missing") + } + + if config.SystemAccessToken == "" { + return errors.New("azuredns: SystemAccessToken is missing") + } + + return nil +}