Skip to content
Merged
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
70 changes: 50 additions & 20 deletions pkg/auth/cloud/gcp/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ func SetupFiles(
// We use "authorized_user" type which requires client_id and client_secret.
// These are the public gcloud CLI credentials (publicly documented, used by gcloud itself).
// Without these, the Google Cloud SDK's threelegged.go throws "auth: client ID must be provided".
clientID, clientSecret, err := resolveADCClientCredentials()
if err != nil {
return nil, err
}
clientID, clientSecret := resolveADCClientCredentials()
adcContent := &AuthorizedUserContent{
Type: "authorized_user",
AccessToken: creds.AccessToken,
Expand Down Expand Up @@ -84,42 +81,71 @@ func SetupFiles(
return paths, nil
}

// formatTokenExpiry formats a time as RFC3339 UTC, returning empty for zero times.
func formatTokenExpiry(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}

// adcClientCredentials holds the OAuth client ID and secret read from an ADC file.
type adcClientCredentials struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}

func resolveADCClientCredentials() (string, string, error) {
clientID := strings.TrimSpace(os.Getenv("ATMOS_GCP_ADC_CLIENT_ID"))
if clientID == "" {
clientID = "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
// resolveADCClientCredentials returns the OAuth client ID and secret for ADC files.
// It treats ID/secret as an atomic pair: env overrides → existing ADC file → public gcloud defaults.
// Always returns a valid pair (falls back to public gcloud defaults on any failure).
func resolveADCClientCredentials() (string, string) {
// Default client ID/secret are the public gcloud CLI OAuth credentials.
// These are publicly documented (e.g. in Google's cloud-sdk source) and are
// used by gcloud itself. They are safe to hardcode as a fallback — the
// client_secret for "installed" / "native" OAuth apps is NOT confidential.
const (
defaultClientID = "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
defaultClientSecret = "d-FL95Q19q7MQmFpd7hHD0Ty" //nolint:gosec // gitleaks:allow -- Public gcloud CLI OAuth secret, not confidential.
)

// Treat client ID/secret as an atomic pair: if a custom client ID is
// provided, the matching secret must also be set. A mismatched pair
// (custom ID + default secret) would fail OAuth refresh with invalid_client.
//nolint:forbidigo // Direct os.Getenv required: called during auth before Viper/flags are wired.
customClientID := strings.TrimSpace(os.Getenv("ATMOS_GCP_ADC_CLIENT_ID"))
//nolint:forbidigo // Direct os.Getenv required: called during auth before Viper/flags are wired.
customClientSecret := strings.TrimSpace(os.Getenv("ATMOS_GCP_ADC_CLIENT_SECRET"))
Comment thread
aknysh marked this conversation as resolved.

if customClientID != "" && customClientSecret != "" {
// Both provided — use the custom pair as-is.
return customClientID, customClientSecret
}

clientSecret := strings.TrimSpace(os.Getenv("ATMOS_GCP_ADC_CLIENT_SECRET"))
if clientSecret != "" {
return clientID, clientSecret, nil
if customClientSecret != "" {
// Secret without ID — pair with default ID.
return defaultClientID, customClientSecret
}

// Ignore custom ID when secret is absent — using a custom ID with the
// default gcloud secret would produce a mismatched pair that fails on
// token refresh.

// Try reading from existing ADC file (e.g. from `gcloud auth application-default login`).
adcCreds, err := readADCClientCredentials()
if err != nil {
return "", "", err
}
if adcCreds.ClientID != "" {
clientID = adcCreds.ClientID
if err == nil && adcCreds.ClientID != "" && adcCreds.ClientSecret != "" {
// ADC file has a complete pair — use it.
return adcCreds.ClientID, adcCreds.ClientSecret
}
if adcCreds.ClientSecret == "" {
return "", "", fmt.Errorf("%w: ADC client secret is missing; run `gcloud auth application-default login` or set ATMOS_GCP_ADC_CLIENT_SECRET", errUtils.ErrInvalidAuthConfig)
}
return clientID, adcCreds.ClientSecret, nil

// Fall back to public gcloud defaults. This enables CI environments (e.g.
// GitHub Actions with WIF) where no ADC file exists and no env var is set.
// The ADC file still works — the access_token is the important part; the
// client_id/secret are only needed for token refresh via OAuth, which atmos
// manages externally.
return defaultClientID, defaultClientSecret
}

// readADCClientCredentials reads OAuth client credentials from the Application Default Credentials file.
func readADCClientCredentials() (*adcClientCredentials, error) {
path := adcCredentialsPath()
if path == "" {
Expand All @@ -139,10 +165,14 @@ func readADCClientCredentials() (*adcClientCredentials, error) {
return &creds, nil
}

// adcCredentialsPath returns the path to the ADC credentials file, checking
// GOOGLE_APPLICATION_CREDENTIALS, CLOUDSDK_CONFIG, and the default gcloud config directory.
func adcCredentialsPath() string {
//nolint:forbidigo // Direct os.Getenv required: called during auth before Viper/flags are wired.
if path := strings.TrimSpace(os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")); path != "" {
return path
}
//nolint:forbidigo // Direct os.Getenv required: called during auth before Viper/flags are wired.
if configDir := strings.TrimSpace(os.Getenv("CLOUDSDK_CONFIG")); configDir != "" {
return filepath.Join(configDir, "application_default_credentials.json")
}
Expand Down
39 changes: 31 additions & 8 deletions pkg/auth/cloud/gcp/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/cloudposse/atmos/pkg/schema"
)

// writeADCClientCredentials creates a temporary ADC credentials file for testing.
func writeADCClientCredentials(t *testing.T) string {
t.Helper()

Expand Down Expand Up @@ -146,10 +147,16 @@ func TestSetup(t *testing.T) {
}
}

func TestSetupFiles_NoADCSecretAndNoADCFile(t *testing.T) {
func TestSetupFiles_NoADCSecretAndNoADCFile_FallsBackToDefaults(t *testing.T) {
// When no ADC credentials file exists and no env vars are set,
// resolveADCClientCredentials falls back to the public gcloud defaults.
// This enables CI environments (e.g. GitHub Actions with WIF) where
// no ADC file exists.
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(tmp, "missing.json"))
t.Setenv("ATMOS_GCP_ADC_CLIENT_SECRET", "")
os.Unsetenv("ATMOS_GCP_ADC_CLIENT_SECRET")

ctx := context.Background()
providerName := "gcp-adc"
Expand All @@ -159,9 +166,9 @@ func TestSetupFiles_NoADCSecretAndNoADCFile(t *testing.T) {
ProjectID: "test-project",
}

_, err := SetupFiles(ctx, testRealm, providerName, "setup-identity", creds)
require.Error(t, err)
assert.Contains(t, err.Error(), "application-default login")
paths, err := SetupFiles(ctx, testRealm, providerName, "setup-identity", creds)
require.NoError(t, err)
require.Len(t, paths, 3)
}

func TestCleanup(t *testing.T) {
Expand Down Expand Up @@ -349,8 +356,7 @@ func TestResolveADCClientCredentials_WithEnvVar(t *testing.T) {
t.Setenv("ATMOS_GCP_ADC_CLIENT_SECRET", "env-secret")
t.Setenv("ATMOS_GCP_ADC_CLIENT_ID", "env-client-id")

clientID, clientSecret, err := resolveADCClientCredentials()
require.NoError(t, err)
clientID, clientSecret := resolveADCClientCredentials()
assert.Equal(t, "env-client-id", clientID)
assert.Equal(t, "env-secret", clientSecret)
}
Expand All @@ -359,9 +365,26 @@ func TestResolveADCClientCredentials_SecretOnlyFromEnv(t *testing.T) {
t.Setenv("ATMOS_GCP_ADC_CLIENT_SECRET", "env-secret-only")
t.Setenv("ATMOS_GCP_ADC_CLIENT_ID", "")

clientID, clientSecret, err := resolveADCClientCredentials()
require.NoError(t, err)
clientID, clientSecret := resolveADCClientCredentials()
// Should use default client ID when env var is empty.
assert.Equal(t, "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", clientID)
assert.Equal(t, "env-secret-only", clientSecret)
}

func TestResolveADCClientCredentials_CustomIDWithoutSecret(t *testing.T) {
// Custom client ID without a matching secret must fall back to the full
// default pair — a mismatched pair (custom ID + default secret) would
// cause invalid_client errors during OAuth token refresh.
t.Setenv("ATMOS_GCP_ADC_CLIENT_ID", "custom-client-id")
t.Setenv("ATMOS_GCP_ADC_CLIENT_SECRET", "")

// Ensure no ADC file interferes.
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(tmp, "missing.json"))

clientID, clientSecret := resolveADCClientCredentials()
// Both should be defaults — custom ID is ignored when secret is absent.
assert.Equal(t, "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", clientID)
assert.Equal(t, "d-FL95Q19q7MQmFpd7hHD0Ty", clientSecret)
}
Loading
Loading