Files
opentf/internal/backend/remote-state/azure/auth/oidc_auth.go
2025-09-15 19:22:17 +01:00

172 lines
5.0 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/tfdiags"
)
type OIDCAuthConfig struct {
UseOIDC bool
OIDCToken string
OIDCTokenFilePath string
OIDCRequestURL string
OIDCRequestToken string
}
type oidcAuth struct{}
var _ AuthMethod = &oidcAuth{}
func (cred *oidcAuth) Name() string {
return "OpenID Connect Auth"
}
func (cred *oidcAuth) Construct(ctx context.Context, config *Config) (azcore.TokenCredential, error) {
client := httpclient.New(ctx)
clientId, err := consolidateClientId(config)
if err != nil {
// This should never happen; this is checked in the Validate function
return nil, err
}
var token string
if config.OIDCToken == "" && config.OIDCTokenFilePath == "" {
token, err = getTokenFromRemote(client, config.OIDCAuthConfig)
if err != nil {
return nil, err
}
} else {
token, err = consolidateToken(config)
if err != nil {
return nil, err
}
}
return azidentity.NewClientAssertionCredential(
config.TenantID,
clientId,
func(_ context.Context) (string, error) {
return token, nil
},
&azidentity.ClientAssertionCredentialOptions{
ClientOptions: clientOptions(client, config.CloudConfig),
},
)
}
type TokenResponse struct {
Value string `json:"value"`
}
func getTokenFromRemote(client *http.Client, config OIDCAuthConfig) (string, error) {
// GET from the request URL, using the bearer token
req, err := http.NewRequest(http.MethodGet, config.OIDCRequestURL, nil)
if err != nil {
return "", fmt.Errorf("malformed token request: %w", err)
}
req.Header.Add("Authorization", "Bearer "+config.OIDCRequestToken)
req.Header.Add("Accept", "application/json; api-version=2.0")
req.Header.Add("Content-Type", "application/json")
query := req.URL.Query()
query.Set("audience", "api://AzureADTokenExchange")
req.URL.RawQuery = query.Encode()
// Read the response
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error obtaining token: %w", err)
}
defer resp.Body.Close()
rawToken, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("io error reading token response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("non-2xx response: status code %d, body: %s", resp.StatusCode, rawToken)
}
var token TokenResponse
// Provide that response as the access token.
err = json.Unmarshal(rawToken, &token)
if err != nil {
return "", fmt.Errorf("error parsing json of token response body: %w", err)
}
return token.Value, nil
}
func consolidateToken(config *Config) (string, error) {
return consolidateFileAndValue(config.OIDCToken, config.OIDCTokenFilePath, "token", true)
}
func (cred *oidcAuth) Validate(ctx context.Context, config *Config) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if !config.UseOIDC {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
"OpenID Connect Auth is disabled when use_oidc or the environment variable ARM_USE_OIDC are unset or set explicitly to false.",
))
return diags
}
if config.TenantID == "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
"Tenant ID is missing.",
))
}
_, err := consolidateClientId(config)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
fmt.Sprintf("The Client ID is misconfigured: %s.", tfdiags.FormatError(err)),
))
}
directTokenUnset := config.OIDCToken == "" && config.OIDCTokenFilePath == ""
indirectTokenUnset := config.OIDCRequestURL == "" || config.OIDCRequestToken == ""
if directTokenUnset && indirectTokenUnset {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
"An access token must be provided, either directly with a variable or through a file, or indirectly through a request URL and request token (as in GitHub Actions).",
))
}
if directTokenUnset {
// check request URL and token
_, err := getTokenFromRemote(httpclient.New(ctx), config.OIDCAuthConfig)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
fmt.Sprintf("Tried to test fetching the token, but received this error instead: %s.", tfdiags.FormatError(err)),
))
}
}
// This will work, even if both token and file path are empty
if _, err := consolidateToken(config); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid Azure OpenID Connect Auth",
fmt.Sprintf("The token is misconfigured: %s", err.Error()),
))
}
return diags
}
func (cred *oidcAuth) AugmentConfig(_ context.Context, config *Config) error {
return checkNamesForAccessKeyCredentials(config.StorageAddresses)
}