From 6cc3c40bc1e0eba0b8ea8c16877727616dfea6fe Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 9 Jul 2026 20:50:25 +0200 Subject: [PATCH 1/4] feat: Modular Identity SDK Signed-off-by: Jorge Turrado --- README.md | 13 + core/auth/auth.go | 280 +++++++++++------- core/auth/auth_test.go | 154 +++++++--- core/auth/token_provider_roundtripper.go | 46 +++ core/auth/token_provider_roundtripper_test.go | 74 +++++ core/clients/key_flow.go | 25 +- core/identity/chained_provider.go | 136 +++++++++ core/identity/chained_provider_test.go | 143 +++++++++ core/identity/doc.go | 6 + core/identity/identity.go | 26 ++ core/identity/instance_metadata_provider.go | 145 +++++++++ .../instance_metadata_provider_test.go | 112 +++++++ core/identity/log.go | 50 ++++ core/identity/service_account_key_provider.go | 253 ++++++++++++++++ .../service_account_key_provider_test.go | 107 +++++++ core/identity/static_token_provider.go | 56 ++++ core/identity/static_token_provider_test.go | 34 +++ core/identity/token.go | 27 ++ core/identity/types.go | 30 ++ .../workload_identity_federation_provider.go | 184 ++++++++++++ ...kload_identity_federation_provider_test.go | 90 ++++++ 21 files changed, 1832 insertions(+), 159 deletions(-) create mode 100644 core/auth/token_provider_roundtripper.go create mode 100644 core/auth/token_provider_roundtripper_test.go create mode 100644 core/identity/chained_provider.go create mode 100644 core/identity/chained_provider_test.go create mode 100644 core/identity/doc.go create mode 100644 core/identity/identity.go create mode 100644 core/identity/instance_metadata_provider.go create mode 100644 core/identity/instance_metadata_provider_test.go create mode 100644 core/identity/log.go create mode 100644 core/identity/service_account_key_provider.go create mode 100644 core/identity/service_account_key_provider_test.go create mode 100644 core/identity/static_token_provider.go create mode 100644 core/identity/static_token_provider_test.go create mode 100644 core/identity/token.go create mode 100644 core/identity/types.go create mode 100644 core/identity/workload_identity_federation_provider.go create mode 100644 core/identity/workload_identity_federation_provider_test.go diff --git a/README.md b/README.md index c2e774437..2d1e90305 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,19 @@ For each authentication method, the try order is: 2. Key Flow 3. Token Flow +### Modular Identity Package (Initial) + +An initial modular identity package is available at `core/identity`. + +It introduces a minimal token contract and composable providers: + +- `identity.TokenProvider` with `Token(ctx, options)` +- `identity.ServiceAccountKeyProvider` +- `identity.WorkloadIdentityFederationProvider` +- `identity.InstanceMetadataProvider` +- `identity.ChainedProvider` + + ### Using the Workload Identity Fedearion Flow 1. Create a service account trusted relation in the STACKIT Portal: diff --git a/core/auth/auth.go b/core/auth/auth.go index 273bcb018..8629108b7 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -6,10 +6,11 @@ import ( "net/http" "os" "path/filepath" - "strings" + "time" "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/identity" ) type credentialType string @@ -78,33 +79,87 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { } // DefaultAuth will search for a valid service account key or token in several locations. -// It will first try to use the key flow, by looking into the variables STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_SERVICE_ACCOUNT_KEY_PATH, +// It will first try to use the instance metadata service (IMS), which is available on STACKIT VMs. +// If IMS is not available, it will try the workload identity federation (WIF) flow. +// If WIF is not available, it will try to use the key flow, by looking into the variables STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_SERVICE_ACCOUNT_KEY_PATH, // STACKIT_PRIVATE_KEY and STACKIT_PRIVATE_KEY_PATH. If the keys cannot be retrieved, it will check the credentials file located in STACKIT_CREDENTIALS_PATH, if specified, or in // $HOME/.stackit/credentials.json as a fallback. If the key are found and are valid, the KeyAuth flow is used. // If the key flow cannot be used, it will try to find a token in the STACKIT_SERVICE_ACCOUNT_TOKEN. If not present, it will // search in the credentials file. If the token is found, the TokenAuth flow is used. // DefaultAuth returns an http.RoundTripper that can be used to make authenticated requests. -// In case the token is not found, DefaultAuth fails. +// In case no valid credentials were found, DefaultAuth fails. func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { if cfg == nil { cfg = &config.Configuration{} } - // WIF flow - rt, err = WorkloadIdentityFederationAuth(cfg) - if err != nil { - // Key flow - rt, err = KeyAuth(cfg) - if err != nil { - keyFlowErr := err - // Token flow - rt, err = TokenAuth(cfg) - if err != nil { - return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %w", keyFlowErr.Error(), err) + providers := []identity.TokenProvider{} + + // Try Instance Metadata Service (IMS) with aggressive timeout to fail fast if not in cloud + email := getServiceAccountEmail(cfg) + if email != "" { + imsClient := &http.Client{Timeout: 2 * time.Second} + if imsProvider, err := identity.NewInstanceMetadataProvider(identity.InstanceMetadataProviderConfig{ + ServiceAccountEmail: email, + HTTPClient: imsClient, + }); err == nil { + providers = append(providers, imsProvider) + } + } + + // Try Workload Identity Federation + if wifProvider, err := identity.NewWorkloadIdentityFederationProvider(identity.WorkloadIdentityFederationProviderConfig{ + TokenURL: cfg.TokenCustomUrl, + ClientID: cfg.ServiceAccountEmail, + FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, + HTTPClient: cfg.HTTPClient, + }); err == nil { + providers = append(providers, wifProvider) + } + + // Try Service Account Key - provider handles env var resolution, fallback to credentials file + if keyProvider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ + ServiceAccountKey: cfg.ServiceAccountKey, + PrivateKey: cfg.PrivateKey, + TokenURL: cfg.TokenCustomUrl, + HTTPClient: cfg.HTTPClient, + }); err == nil { + providers = append(providers, keyProvider) + } else { + // Try resolving from credentials file if direct/env resolution fails + keyCfg := *cfg + if getServiceAccountKeyAndPrivateKeyWithFallback(&keyCfg) == nil { + if keyProvider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ + ServiceAccountKey: keyCfg.ServiceAccountKey, + PrivateKey: keyCfg.PrivateKey, + TokenURL: keyCfg.TokenCustomUrl, + HTTPClient: keyCfg.HTTPClient, + }); err == nil { + providers = append(providers, keyProvider) } } } - return rt, nil + + // Try Static Token - provider handles env var resolution, fallback to credentials file + if staticProvider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ + Token: cfg.Token, + }); err == nil { + providers = append(providers, staticProvider) + } else if token, err := getTokenWithFallback(cfg); err == nil && token != "" { + // Try resolving from credentials file if env resolution fails + if staticProvider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ + Token: token, + }); err == nil { + providers = append(providers, staticProvider) + } + } + + chained, err := identity.NewChainedProvider(providers...) + if err != nil { + return nil, fmt.Errorf("error initializing chained provider: %w", err) + } + + return newTokenProviderRoundTripper(chained, getTransportFromConfig(cfg)), nil } // NoAuth configures a flow without authentication and returns an http.RoundTripper @@ -134,124 +189,76 @@ func NoAuth(cfgs ...*config.Configuration) (rt http.RoundTripper, err error) { // TokenAuth configures the token flow and returns an http.RoundTripper // that can be used to make authenticated requests using a token func TokenAuth(cfg *config.Configuration) (http.RoundTripper, error) { - // Check token - if cfg.Token == "" { - token, tokenSet := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_TOKEN") - if !tokenSet || token == "" { - credentials, err := readCredentialsFile(cfg.CredentialsFilePath) - if err != nil { - return nil, fmt.Errorf("reading from credentials file: %w", err) - } - token, err = readCredential(tokenCredentialType, credentials) - if err != nil { - return nil, fmt.Errorf("STACKIT_SERVICE_ACCOUNT_TOKEN not set. Trying to read from credentials file: %w", err) - } - } - cfg.Token = token - } - - tokenCfg := clients.TokenFlowConfig{ - ServiceAccountToken: cfg.Token, + token, err := getTokenWithFallback(cfg) + if err != nil { + return nil, fmt.Errorf("resolving token: %w", err) } - - if cfg.HTTPClient != nil && cfg.HTTPClient.Transport != nil { - tokenCfg.HTTPTransport = cfg.HTTPClient.Transport + if token == "" { + return nil, fmt.Errorf("STACKIT_SERVICE_ACCOUNT_TOKEN not set and no token found in credentials file") } - client := &clients.TokenFlow{} - if err := client.Init(&tokenCfg); err != nil { - return nil, fmt.Errorf("error initializing client: %w", err) + provider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ + Token: token, + }) + if err != nil { + return nil, fmt.Errorf("error initializing static token provider: %w", err) } - return client, nil + return newTokenProviderRoundTripper(provider, getTransportFromConfig(cfg)), nil } // KeyAuth configures the key flow and returns an http.RoundTripper -// that can be used to make authenticated requests using an access token +// that can be used to make authenticated requests using an access token. // The KeyFlow requires a service account key and a private key. // -// If the private key is not provided explicitly, KeyAuth will check if there is one included -// in the service account key and use that one. An explicitly provided private key takes -// precedence over the one on the service account key. +// Configuration is resolved in the following order: +// 1. Values from cfg parameter +// 2. Environment variables (STACKIT_SERVICE_ACCOUNT_KEY, STACKIT_PRIVATE_KEY, etc.) +// 3. Credentials file +// 4. Private key embedded in the service account key func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) { - err := getServiceAccountKey(cfg) - if err != nil { - return nil, fmt.Errorf("configuring key authentication: service account key could not be found: %w", err) + if cfg == nil { + cfg = &config.Configuration{} } - // Unmarshal service account key to check if private key is present - var serviceAccountKey = &clients.ServiceAccountKeyResponse{} - err = json.Unmarshal([]byte(cfg.ServiceAccountKey), serviceAccountKey) - if err != nil { - var errorSuffix string - if strings.HasPrefix(cfg.ServiceAccountKey, "-----BEGIN") { - errorSuffix = " - it seems like the provided service account key is in PEM format. Please provide it in JSON format." - } - return nil, fmt.Errorf("unmarshalling service account key: %w%s", err, errorSuffix) + if err := getServiceAccountKeyAndPrivateKeyWithFallback(cfg); err != nil { + return nil, fmt.Errorf("configuring key authentication: %w", err) } - // Try to get private key from configuration, environment or credentials file - err = getPrivateKey(cfg) + provider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ + ServiceAccountKey: cfg.ServiceAccountKey, + PrivateKey: cfg.PrivateKey, + TokenURL: cfg.TokenCustomUrl, + HTTPClient: cfg.HTTPClient, + }) if err != nil { - // If the private key is not provided explicitly, try to extract private key from the service account key - // and use it if present - var extractedPrivateKey string - if serviceAccountKey.Credentials != nil && serviceAccountKey.Credentials.PrivateKey != nil { - extractedPrivateKey = *serviceAccountKey.Credentials.PrivateKey - } - if extractedPrivateKey == "" { - return nil, fmt.Errorf("configuring key authentication: private key is not part of the service account key and could not be found: %w", err) - } - cfg.PrivateKey = extractedPrivateKey - } - - if cfg.TokenCustomUrl == "" { - tokenCustomUrl, tokenUrlSet := os.LookupEnv("STACKIT_TOKEN_BASEURL") - if tokenUrlSet { - cfg.TokenCustomUrl = tokenCustomUrl - } - } - - keyCfg := clients.KeyFlowConfig{ - ServiceAccountKey: serviceAccountKey, - PrivateKey: cfg.PrivateKey, - TokenUrl: cfg.TokenCustomUrl, - BackgroundTokenRefreshContext: cfg.BackgroundTokenRefreshContext, - } - - if cfg.HTTPClient != nil && cfg.HTTPClient.Transport != nil { - keyCfg.HTTPTransport = cfg.HTTPClient.Transport - } - - client := &clients.KeyFlow{} - if err := client.Init(&keyCfg); err != nil { return nil, fmt.Errorf("error initializing client: %w", err) } - return client, nil + return newTokenProviderRoundTripper(provider, getTransportFromConfig(cfg)), nil } // WorkloadIdentityFederationAuth configures the wif flow and returns an http.RoundTripper // that can be used to make authenticated requests using an access token func WorkloadIdentityFederationAuth(cfg *config.Configuration) (http.RoundTripper, error) { - wifConfig := clients.WorkloadIdentityFederationFlowConfig{ - TokenUrl: cfg.TokenCustomUrl, - BackgroundTokenRefreshContext: cfg.BackgroundTokenRefreshContext, - ClientID: cfg.ServiceAccountEmail, - TokenExpiration: cfg.ServiceAccountFederatedTokenExpiration, - FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, + provider, err := identity.NewWorkloadIdentityFederationProvider(identity.WorkloadIdentityFederationProviderConfig{ + TokenURL: cfg.TokenCustomUrl, + ClientID: cfg.ServiceAccountEmail, + FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, + HTTPClient: cfg.HTTPClient, + }) + if err != nil { + return nil, fmt.Errorf("error initializing client: %w", err) } - if cfg.HTTPClient != nil && cfg.HTTPClient.Transport != nil { - wifConfig.HTTPTransport = cfg.HTTPClient.Transport - } + return newTokenProviderRoundTripper(provider, getTransportFromConfig(cfg)), nil +} - client := &clients.WorkloadIdentityFederationFlow{} - if err := client.Init(&wifConfig); err != nil { - return nil, fmt.Errorf("error initializing client: %w", err) +func getTransportFromConfig(cfg *config.Configuration) http.RoundTripper { + if cfg != nil && cfg.HTTPClient != nil { + return cfg.HTTPClient.Transport } - - return client, nil + return nil } // readCredentialsFile reads the credentials file from the specified path and returns Credentials @@ -321,8 +328,6 @@ func readCredential(cred credentialType, credentials *Credentials) (string, erro // getServiceAccountEmail searches for an email in the following order: client configuration, environment variable, credentials file. // is not required for authentication, so it can be empty. -// -// Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. func getServiceAccountEmail(cfg *config.Configuration) string { if cfg.ServiceAccountEmail != "" { return cfg.ServiceAccountEmail @@ -394,3 +399,64 @@ func getServiceAccountKey(cfg *config.Configuration) error { func getPrivateKey(cfg *config.Configuration) error { return getKey(&cfg.PrivateKey, &cfg.PrivateKeyPath, "STACKIT_PRIVATE_KEY_PATH", "STACKIT_PRIVATE_KEY", privateKeyPathCredentialType, privateKeyCredentialType, cfg.CredentialsFilePath) } + +// getTokenWithFallback resolves a token from config, environment, or credentials file +func getTokenWithFallback(cfg *config.Configuration) (string, error) { + if cfg.Token != "" { + return cfg.Token, nil + } + + // Try environment variable + if token, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_TOKEN"); exists && token != "" { + return token, nil + } + + // Try credentials file + credentials, err := readCredentialsFile(cfg.CredentialsFilePath) + if err != nil { + return "", fmt.Errorf("reading from credentials file: %w", err) + } + + token, err := readCredential(tokenCredentialType, credentials) + if err != nil { + return "", fmt.Errorf("token not found in credentials file: %w", err) + } + + return token, nil +} + +// getServiceAccountKeyAndPrivateKeyWithFallback resolves service account key and private key with fallback to credentials file +// It also attempts to extract private key from the service account key JSON if not provided separately +func getServiceAccountKeyAndPrivateKeyWithFallback(cfg *config.Configuration) error { + // Resolve service account key + err := getServiceAccountKey(cfg) + if err != nil { + return fmt.Errorf("service account key could not be found: %w", err) + } + + // Try to get private key + err = getPrivateKey(cfg) + if err != nil { + // If the private key is not provided explicitly, try to extract it from the service account key JSON + cfg.PrivateKey = "" + } + + // If private key is still empty, try to extract from service account key JSON + if cfg.PrivateKey == "" && cfg.ServiceAccountKey != "" { + var serviceAccountKey = &identity.ServiceAccountJson{} + if err := json.Unmarshal([]byte(cfg.ServiceAccountKey), serviceAccountKey); err == nil { + if serviceAccountKey.Credentials != nil && serviceAccountKey.Credentials.PrivateKey != nil { + cfg.PrivateKey = *serviceAccountKey.Credentials.PrivateKey + } + } + } + + // Resolve token URL from env if not set + if cfg.TokenCustomUrl == "" { + if tokenURL, exists := os.LookupEnv("STACKIT_TOKEN_BASEURL"); exists && tokenURL != "" { + cfg.TokenCustomUrl = tokenURL + } + } + + return nil +} diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index f67ca55ce..2c087fd13 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -8,7 +8,6 @@ import ( "encoding/pem" "net/http" "os" - "reflect" "strings" "testing" "time" @@ -16,8 +15,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/identity" ) func setTemporaryHome(t *testing.T) { @@ -30,12 +29,12 @@ func setTemporaryHome(t *testing.T) { } } -func fixtureServiceAccountKey(mods ...func(*clients.ServiceAccountKeyResponse)) *clients.ServiceAccountKeyResponse { +func fixtureServiceAccountKey(mods ...func(*identity.ServiceAccountJson)) *identity.ServiceAccountJson { validUntil := time.Now().Add(time.Hour) - serviceAccountKeyResponse := &clients.ServiceAccountKeyResponse{ + serviceAccountKeyResponse := &identity.ServiceAccountJson{ Active: true, CreatedAt: time.Now(), - Credentials: &clients.ServiceAccountKeyCredentials{ + Credentials: &identity.ServiceAccountKeyCredentials{ Aud: "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud", Iss: "stackit@sa.stackit.cloud", Kid: uuid.New().String(), @@ -62,6 +61,15 @@ func createCredentialsKeyJson(serviceAccountKey, privateKey string) ([]byte, err return json.Marshal(tempMap) } +// helper function to create a valid JWT token for testing +func createValidJWT() (string, error) { + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "test-subject", + }).SignedString([]byte("test-secret")) + return token, err +} + // Error cases are tested in the NoAuth, KeyAuth, TokenAuth and DefaultAuth functions func TestSetupAuth(t *testing.T) { privateKey, err := generatePrivateKey() @@ -171,6 +179,36 @@ func TestSetupAuth(t *testing.T) { t.Fatalf("Writing credentials json to temporary file: %s", err) } + // Create a valid JWT for token-based authentication + validToken, err := createValidJWT() + if err != nil { + t.Fatalf("Creating valid JWT: %s", err) + } + + // Create a credentials file with valid JWT token + credentialsTokenFile, errs := os.CreateTemp("", "temp-*.txt") + if errs != nil { + t.Fatalf("Creating temporary credentials token file: %s", err) + } + defer func() { + _ = credentialsTokenFile.Close() + err := os.Remove(credentialsTokenFile.Name()) + if err != nil { + t.Fatalf("Removing temporary credentials token file: %s", err) + } + }() + + credTokenJson, err := json.Marshal(map[string]interface{}{ + "STACKIT_SERVICE_ACCOUNT_TOKEN": validToken, + }) + if err != nil { + t.Fatalf("Creating credentials token JSON: %s", err) + } + _, errs = credentialsTokenFile.WriteString(string(credTokenJson)) + if errs != nil { + t.Fatalf("Writing credentials token file: %s", err) + } + for _, test := range []struct { desc string config *config.Configuration @@ -228,7 +266,7 @@ func TestSetupAuth(t *testing.T) { { desc: "custom_config_token", config: &config.Configuration{ - Token: "token", + Token: validToken, }, setToken: false, setCredentialsFilePathToken: false, @@ -246,6 +284,15 @@ func TestSetupAuth(t *testing.T) { } { t.Run(test.desc, func(t *testing.T) { setTemporaryHome(t) + + // For custom_config_path test, use the dynamic credentials token file + cfg := test.config + if test.desc == "custom_config_path" { + cfg = &config.Configuration{ + CredentialsFilePath: credentialsTokenFile.Name(), + } + } + if test.setKeys { t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY", string(saKey)) t.Setenv("STACKIT_PRIVATE_KEY", privateKey) @@ -263,13 +310,13 @@ func TestSetupAuth(t *testing.T) { } if test.setToken { - t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "test-token") + t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", validToken) } else { t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "") } if test.setCredentialsFilePathToken { - t.Setenv("STACKIT_CREDENTIALS_PATH", "test_resources/test_credentials_bar.json") + t.Setenv("STACKIT_CREDENTIALS_PATH", credentialsTokenFile.Name()) } else if test.setCredentialsFilePathKey { t.Setenv("STACKIT_CREDENTIALS_PATH", credentialsKeyFile.Name()) } else { @@ -284,7 +331,7 @@ func TestSetupAuth(t *testing.T) { t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email") - authRoundTripper, err := SetupAuth(test.config) + authRoundTripper, err := SetupAuth(cfg) if err != nil && test.isValid { t.Fatalf("Test returned error on valid test case: %v", err) @@ -482,6 +529,36 @@ func TestDefaultAuth(t *testing.T) { t.Fatalf("Writing credentials json to temporary file: %s", err) } + // Create a valid JWT for static token-based authentication + validStaticToken, err := createValidJWT() + if err != nil { + t.Fatalf("Creating valid JWT: %s", err) + } + + // Create a credentials file with valid JWT token + credentialsTokenFile, errs := os.CreateTemp("", "temp-*.txt") + if errs != nil { + t.Fatalf("Creating temporary credentials token file: %s", err) + } + defer func() { + _ = credentialsTokenFile.Close() + err := os.Remove(credentialsTokenFile.Name()) + if err != nil { + t.Fatalf("Removing temporary credentials token file: %s", err) + } + }() + + credTokenJson, err := json.Marshal(map[string]interface{}{ + "STACKIT_SERVICE_ACCOUNT_TOKEN": validStaticToken, + }) + if err != nil { + t.Fatalf("Creating credentials token JSON: %s", err) + } + _, errs = credentialsTokenFile.WriteString(string(credTokenJson)) + if errs != nil { + t.Fatalf("Writing credentials token file: %s", err) + } + for _, test := range []struct { desc string setToken bool @@ -559,7 +636,7 @@ func TestDefaultAuth(t *testing.T) { } if test.setToken { - t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "test-token") + t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", validStaticToken) } else { t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "") } @@ -570,7 +647,12 @@ func TestDefaultAuth(t *testing.T) { t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", "") } - t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email") + // For no_credentials test, ensure no IMS is attempted by not setting email + if test.desc == "no_credentials" { + t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "") + } else { + t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email") + } // Get the default authentication client and ensure that it's not nil authClient, err := DefaultAuth(nil) @@ -587,19 +669,17 @@ func TestDefaultAuth(t *testing.T) { if authClient == nil { t.Fatalf("Client returned is nil for valid test case") } - switch test.expectedFlow { - case "token": - if _, ok := authClient.(*clients.TokenFlow); !ok { - t.Fatalf("Expected token flow, got %s", reflect.TypeOf(authClient)) - } - case "key": - if _, ok := authClient.(*clients.KeyFlow); !ok { - t.Fatalf("Expected key flow, got %s", reflect.TypeOf(authClient)) - } - case "wif": - if _, ok := authClient.(*clients.WorkloadIdentityFederationFlow); !ok { - t.Fatalf("Expected key flow, got %s", reflect.TypeOf(authClient)) - } + // DefaultAuth now always returns a tokenProviderRoundTripper with ChainedProvider + rt, ok := authClient.(*tokenProviderRoundTripper) + if !ok { + t.Fatalf("Expected token provider round tripper") + } + chained, ok := rt.provider.(*identity.ChainedProvider) + if !ok { + t.Fatalf("Expected chained provider, got %T", rt.provider) + } + if chained == nil { + t.Fatalf("ChainedProvider is nil") } } }) @@ -607,6 +687,12 @@ func TestDefaultAuth(t *testing.T) { } func TestTokenAuth(t *testing.T) { + // Generate a valid JWT token for testing + validToken, err := createValidJWT() + if err != nil { + t.Fatalf("Failed to create valid JWT: %v", err) + } + for _, test := range []struct { desc string token string @@ -614,7 +700,7 @@ func TestTokenAuth(t *testing.T) { }{ { desc: "valid_case", - token: "token", + token: validToken, isValid: true, }, { @@ -661,7 +747,7 @@ func TestKeyAuth(t *testing.T) { for _, test := range []struct { desc string - serviceAccountKey *clients.ServiceAccountKeyResponse + serviceAccountKey *identity.ServiceAccountJson includedPrivateKey *string configuredPrivateKey string envVarPrivateKey string @@ -755,13 +841,15 @@ func TestKeyAuth(t *testing.T) { if authClient == nil { t.Fatalf("Client returned is nil for valid test case") } - - keyFlow, ok := authClient.(*clients.KeyFlow) + rt, ok := authClient.(*tokenProviderRoundTripper) if !ok { - t.Fatalf("Could not convert authClient to KeyFlow") + t.Fatalf("Expected token provider round tripper") + } + if _, ok := rt.provider.(*identity.ServiceAccountKeyProvider); !ok { + t.Fatalf("Expected identity service account key provider") } - if keyFlow.GetConfig().PrivateKey != test.expectedPrivateKey { - t.Fatalf("The private key is wrong: expected %s, got %s", test.expectedPrivateKey, keyFlow.GetConfig().PrivateKey) + if cfg.PrivateKey != test.expectedPrivateKey { + t.Fatalf("The private key is wrong: expected %s, got %s", test.expectedPrivateKey, cfg.PrivateKey) } } }) @@ -776,8 +864,8 @@ func TestKeyAuthPemInsteadOfJsonKeyErrorHandling(t *testing.T) { if err == nil { t.Fatalf("error expected") } - if !strings.HasSuffix(err.Error(), "Please provide it in JSON format.") { - t.Fatalf("expected to end with JSON format hint: %s", err) + if !strings.Contains(err.Error(), "parse service account key JSON") { + t.Fatalf("expected parse service account key JSON error: %s", err) } } diff --git a/core/auth/token_provider_roundtripper.go b/core/auth/token_provider_roundtripper.go new file mode 100644 index 000000000..576eac89f --- /dev/null +++ b/core/auth/token_provider_roundtripper.go @@ -0,0 +1,46 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/stackitcloud/stackit-sdk-go/core/identity" +) + +const authorizationSchemeBearer = "Bearer" + +type tokenProviderRoundTripper struct { + provider identity.TokenProvider + rt http.RoundTripper +} + +func newTokenProviderRoundTripper(provider identity.TokenProvider, rt http.RoundTripper) http.RoundTripper { + if rt == nil { + rt = http.DefaultTransport + } + return &tokenProviderRoundTripper{ + provider: provider, + rt: rt, + } +} + +func (r *tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + if r.provider == nil { + return nil, fmt.Errorf("token provider cannot be nil") + } + + token, err := r.provider.Token(req.Context(), identity.TokenRequestOptions{}) + if err != nil { + return nil, fmt.Errorf("get access token from provider: %w", err) + } + if token.AccessToken == "" { + return nil, fmt.Errorf("token provider returned empty access token") + } + + requestCopy := req.Clone(req.Context()) + requestCopy.Header.Set("Authorization", fmt.Sprintf("%s %s", authorizationSchemeBearer, token.AccessToken)) + return r.rt.RoundTrip(requestCopy) +} diff --git a/core/auth/token_provider_roundtripper_test.go b/core/auth/token_provider_roundtripper_test.go new file mode 100644 index 000000000..d341a8132 --- /dev/null +++ b/core/auth/token_provider_roundtripper_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/identity" +) + +type tokenProviderStub struct { + token identity.Token + err error +} + +func (s tokenProviderStub) Token(context.Context, identity.TokenRequestOptions) (identity.Token, error) { + if s.err != nil { + return identity.Token{}, s.err + } + return s.token, nil +} + +type roundTripperStub func(*http.Request) (*http.Response, error) + +func (f roundTripperStub) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestTokenProviderRoundTripper(t *testing.T) { + rt := newTokenProviderRoundTripper(tokenProviderStub{ + token: identity.Token{AccessToken: "token-value", RefreshOn: time.Now().Add(time.Hour)}, + }, roundTripperStub(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer token-value" { + t.Fatalf("unexpected authorization header: %s", req.Header.Get("Authorization")) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header)}, nil + })) + + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + res, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("round trip failed: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("unexpected status code: %d", res.StatusCode) + } +} + +func TestTokenProviderRoundTripperProviderError(t *testing.T) { + rt := newTokenProviderRoundTripper(tokenProviderStub{err: fmt.Errorf("failed")}, roundTripperStub(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("create request: %v", err) + } + + _, err = rt.RoundTrip(req) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "get access token from provider") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/core/clients/key_flow.go b/core/clients/key_flow.go index 1e1b87d4c..8e3058c26 100644 --- a/core/clients/key_flow.go +++ b/core/clients/key_flow.go @@ -18,6 +18,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/core/identity" ) const ( @@ -65,26 +66,12 @@ type KeyFlowConfig struct { // ServiceAccountKeyResponse is the API response // when creating a new SA key -type ServiceAccountKeyResponse struct { - Active bool `json:"active"` - CreatedAt time.Time `json:"createdAt"` - Credentials *ServiceAccountKeyCredentials `json:"credentials"` - ID uuid.UUID `json:"id"` - KeyAlgorithm string `json:"keyAlgorithm"` - KeyOrigin string `json:"keyOrigin"` - KeyType string `json:"keyType"` - PublicKey string `json:"publicKey"` - ValidUntil *time.Time `json:"validUntil,omitempty"` -} +// +// Deprecated: use identity.ServiceAccountKeyResponse. +type ServiceAccountKeyResponse = identity.ServiceAccountJson -type ServiceAccountKeyCredentials struct { - Aud string `json:"aud"` - Iss string `json:"iss"` - Kid string `json:"kid"` - PrivateKey *string `json:"privateKey,omitempty"` - Sub uuid.UUID `json:"sub"` - TokenEndpoint string `json:"tokenEndpoint"` -} +// Deprecated: use identity.ServiceAccountKeyCredentials. +type ServiceAccountKeyCredentials = identity.ServiceAccountKeyCredentials // GetConfig returns the flow configuration func (c *KeyFlow) GetConfig() KeyFlowConfig { diff --git a/core/identity/chained_provider.go b/core/identity/chained_provider.go new file mode 100644 index 000000000..dd51f2a59 --- /dev/null +++ b/core/identity/chained_provider.go @@ -0,0 +1,136 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +const chainedProviderErrorPrefix = "chained provider" + +var _ TokenProvider = (*ChainedProvider)(nil) + +// ChainedProvider is an ordered list of token providers. +type ChainedProvider struct { + cond *sync.Cond + iterating bool + name string + retrySources bool + providers []TokenProvider + successfulProvider TokenProvider +} + +// ChainedProviderOptions contains optional parameters for ChainedProvider. +type ChainedProviderOptions struct { + // RetrySources configures how the chain uses its sources. + // When true, Token always attempts every provider in order until one succeeds. + // When false, after the first success the chain reuses that provider only. + RetrySources bool +} + +// NewChainedProvider creates a chain that tries providers in order until one succeeds. +func NewChainedProvider(providers ...TokenProvider) (*ChainedProvider, error) { + return NewChainedProviderWithOptions(ChainedProviderOptions{}, providers...) +} + +// NewChainedProviderWithOptions creates a chain using the provided options. +func NewChainedProviderWithOptions(options ChainedProviderOptions, providers ...TokenProvider) (*ChainedProvider, error) { + if len(providers) == 0 { + return nil, errors.New("providers must contain at least one TokenProvider") + } + for _, provider := range providers { + if provider == nil { + return nil, errors.New("providers cannot contain nil") + } + } + cp := make([]TokenProvider, len(providers)) + copy(cp, providers) + return &ChainedProvider{ + cond: sync.NewCond(&sync.Mutex{}), + name: "ChainedProvider", + retrySources: options.RetrySources, + providers: cp, + }, nil +} + +// NewChainWithOptions creates a chain using the provided options. +// Deprecated: use NewChainedProviderWithOptions. +func NewChainWithOptions(options ChainedProviderOptions, providers ...TokenProvider) (*ChainedProvider, error) { + return NewChainedProviderWithOptions(options, providers...) +} + +// Token returns the first token retrieved successfully from the configured providers. +func (c *ChainedProvider) Token(ctx context.Context, options TokenRequestOptions) (Token, error) { + if !c.retrySources { + // Ensure only one goroutine at a time iterates sources and sets successfulProvider. + c.cond.L.Lock() + for { + if c.successfulProvider != nil { + provider := c.successfulProvider + c.cond.L.Unlock() + return provider.Token(ctx, options) + } + if !c.iterating { + c.iterating = true + c.cond.L.Unlock() + break + } + c.cond.Wait() + } + } + + errorsByProvider := make([]string, 0, len(c.providers)) + var ( + err error + token Token + successfulProvider TokenProvider + ) + for index, provider := range c.providers { + token, err = provider.Token(ctx, options) + if err == nil { + if token.AccessToken == "" { + msg := fmt.Sprintf("%s (%d): returned empty access token", providerName(provider), index) + errorsByProvider = append(errorsByProvider, msg) + DebugContext(ctx, "identity: provider returned empty access token", "chain", c.name, "provider", providerName(provider), "index", index) + err = fmt.Errorf("%s: %s", chainedProviderErrorPrefix, msg) + continue + } + DebugContext(ctx, "identity: authenticated", "chain", c.name, "provider", providerName(provider)) + successfulProvider = provider + break + } + msg := fmt.Sprintf("%s (%d): %v", providerName(provider), index, err) + DebugContext(ctx, "identity: provider failed", "chain", c.name, "provider", providerName(provider), "index", index, "error", err) + errorsByProvider = append(errorsByProvider, msg) + } + + if c.iterating { + c.cond.L.Lock() + c.successfulProvider = successfulProvider + c.iterating = false + c.cond.L.Unlock() + c.cond.Broadcast() + } + + if err == nil && successfulProvider != nil { + return token, nil + } + ErrorContext(ctx, "identity: all chain providers failed", "chain", c.name, "errors", strings.Join(errorsByProvider, "; ")) + return Token{}, fmt.Errorf("%s: all chain providers failed: %s", chainedProviderErrorPrefix, strings.Join(errorsByProvider, "; ")) +} + +// providerName returns a short human-readable name for a TokenProvider, +// stripping the package path and pointer sigil so the log is readable. +// +// For example: *identity.ServiceAccountKeyProvider → "ServiceAccountKeyProvider" +func providerName(p TokenProvider) string { + raw := fmt.Sprintf("%T", p) + // strip leading "*" and package prefix (e.g. "identity.") + raw = strings.TrimPrefix(raw, "*") + if dot := strings.LastIndex(raw, "."); dot >= 0 { + raw = raw[dot+1:] + } + return raw +} diff --git a/core/identity/chained_provider_test.go b/core/identity/chained_provider_test.go new file mode 100644 index 000000000..17299067e --- /dev/null +++ b/core/identity/chained_provider_test.go @@ -0,0 +1,143 @@ +package identity + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" +) + +type staticProvider struct { + token Token + err error +} + +func (s staticProvider) Token(context.Context, TokenRequestOptions) (Token, error) { + if s.err != nil { + return Token{}, s.err + } + return s.token, nil +} + +func TestChainToken(t *testing.T) { + expected := Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)} + chain, err := NewChainedProvider( + staticProvider{err: fmt.Errorf("first failed")}, + staticProvider{token: expected}, + ) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + token, err := chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != expected.AccessToken { + t.Fatalf("unexpected access token: %s", token.AccessToken) + } +} + +func TestChainTokenAllFailures(t *testing.T) { + chain, err := NewChainedProvider( + staticProvider{err: fmt.Errorf("first failed")}, + staticProvider{err: fmt.Errorf("second failed")}, + ) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "all chain providers failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChainNoProviders(t *testing.T) { + _, err := NewChainedProvider() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChainCachesSuccessfulProviderByDefault(t *testing.T) { + var firstCalls int32 + var secondCalls int32 + + first := staticProvider{err: fmt.Errorf("first failed")} + second := staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + + countingFirst := countingProvider{inner: first, count: &firstCalls} + countingSecond := countingProvider{inner: second, count: &secondCalls} + + chain, err := NewChainedProvider(countingFirst, countingSecond) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + + if atomic.LoadInt32(&firstCalls) != 1 { + t.Fatalf("expected first provider to be called once, got %d", firstCalls) + } + if atomic.LoadInt32(&secondCalls) != 2 { + t.Fatalf("expected successful provider to be called twice, got %d", secondCalls) + } +} + +func TestChainRetrySourcesOption(t *testing.T) { + var firstCalls int32 + var secondCalls int32 + + first := staticProvider{err: fmt.Errorf("first failed")} + second := staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + + countingFirst := countingProvider{inner: first, count: &firstCalls} + countingSecond := countingProvider{inner: second, count: &secondCalls} + + chain, err := NewChainedProviderWithOptions(ChainedProviderOptions{RetrySources: true}, countingFirst, countingSecond) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + + if atomic.LoadInt32(&firstCalls) != 2 { + t.Fatalf("expected first provider to be called twice, got %d", firstCalls) + } + if atomic.LoadInt32(&secondCalls) != 2 { + t.Fatalf("expected second provider to be called twice, got %d", secondCalls) + } +} + +type countingProvider struct { + inner TokenProvider + count *int32 +} + +func (c countingProvider) Token(ctx context.Context, options TokenRequestOptions) (Token, error) { + atomic.AddInt32(c.count, 1) + return c.inner.Token(ctx, options) +} diff --git a/core/identity/doc.go b/core/identity/doc.go new file mode 100644 index 000000000..3128f3b46 --- /dev/null +++ b/core/identity/doc.go @@ -0,0 +1,6 @@ +// Package identity contains modular token providers and composition primitives. +// +// This package exposes a minimal contract via TokenProvider and includes +// initial providers for service account key flow, workload identity federation, +// and instance metadata tokens, plus a composable Chain. +package identity diff --git a/core/identity/identity.go b/core/identity/identity.go new file mode 100644 index 000000000..dae94c1ef --- /dev/null +++ b/core/identity/identity.go @@ -0,0 +1,26 @@ +package identity + +import ( + "context" + "time" +) + +// TokenProvider exposes the minimal contract for retrieving an access token. +type TokenProvider interface { + Token(ctx context.Context, options TokenRequestOptions) (Token, error) +} + +// TokenRequestOptions carries optional token request hints for providers. +// +// The initial implementation keeps these fields as forward-compatible extension +// points. Providers may ignore them when not applicable. +type TokenRequestOptions struct { + Scopes []string +} + +// Token is a normalized access token response. +type Token struct { + AccessToken string + ExpiresOn time.Time + RefreshOn time.Time +} diff --git a/core/identity/instance_metadata_provider.go b/core/identity/instance_metadata_provider.go new file mode 100644 index 000000000..b9034b4a2 --- /dev/null +++ b/core/identity/instance_metadata_provider.go @@ -0,0 +1,145 @@ +package identity + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +const ( + instanceMetadataEndpointTemplate = "http://169.254.169.254/stackit/v1/service-accounts//token" + instanceMetadataPathPlaceholder = "" + instanceMetadataDefaultLeeway = time.Minute + instanceMetadataErrorPrefix = "instance metadata provider" +) + +// InstanceMetadataProviderConfig contains the configuration for InstanceMetadataProvider. +type InstanceMetadataProviderConfig struct { + ServiceAccountEmail string + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client +} + +var _ TokenProvider = (*InstanceMetadataProvider)(nil) + +// InstanceMetadata retrieves access tokens from STACKIT VM instance metadata endpoint. +type InstanceMetadataProvider struct { + name string + httpClient *http.Client + serviceAccount string + endpointTemplate string + tokenLeeway time.Duration + + tokenMutex sync.RWMutex + token Token +} + +type instanceMetadataTokenResponse struct { + Token string `json:"token"` + ValidUntil string `json:"validUntil"` +} + +// NewInstanceMetadataProvider creates an InstanceMetadata provider. +func NewInstanceMetadataProvider(cfg InstanceMetadataProviderConfig) (*InstanceMetadataProvider, error) { + if cfg.ServiceAccountEmail == "" { + return nil, fmt.Errorf("%s: service account email cannot be empty", instanceMetadataErrorPrefix) + } + + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = instanceMetadataDefaultLeeway + } + + return &InstanceMetadataProvider{ + name: "InstanceMetadataProvider", + httpClient: httpClient, + serviceAccount: cfg.ServiceAccountEmail, + endpointTemplate: instanceMetadataEndpointTemplate, + tokenLeeway: leeway, + }, nil +} + +// Token returns a valid access token. +func (p *InstanceMetadataProvider) Token(ctx context.Context, _ TokenRequestOptions) (Token, error) { + if p == nil { + return Token{}, fmt.Errorf("%s: provider is not initialized", instanceMetadataErrorPrefix) + } + if ctx == nil { + ctx = context.Background() + } + + now := time.Now().Add(p.tokenLeeway) + p.tokenMutex.RLock() + cached := p.token + p.tokenMutex.RUnlock() + if cached.AccessToken != "" && cached.RefreshOn.After(now) { + return cached, nil + } + + fresh, err := p.requestToken(ctx) + if err != nil { + return Token{}, err + } + DebugContext(ctx, "identity: authenticated", "provider", p.name) + + p.tokenMutex.Lock() + p.token = fresh + p.tokenMutex.Unlock() + + return fresh, nil +} + +func (p *InstanceMetadataProvider) requestToken(ctx context.Context) (Token, error) { + url := strings.ReplaceAll(p.endpointTemplate, instanceMetadataPathPlaceholder, p.serviceAccount) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Token{}, fmt.Errorf("%s: create instance metadata request: %w", instanceMetadataErrorPrefix, err) + } + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request instance metadata token: %w", instanceMetadataErrorPrefix, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Token{}, fmt.Errorf("%s: instance metadata request failed with status %d", instanceMetadataErrorPrefix, res.StatusCode) + } + + var tokenResponse instanceMetadataTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode instance metadata token response: %w", instanceMetadataErrorPrefix, err) + } + if tokenResponse.Token == "" { + return Token{}, fmt.Errorf("%s: instance metadata token response did not include token", instanceMetadataErrorPrefix) + } + if tokenResponse.ValidUntil == "" { + return Token{}, fmt.Errorf("%s: instance metadata token response did not include validUntil", instanceMetadataErrorPrefix) + } + + expiresAt, err := time.Parse(time.RFC3339Nano, tokenResponse.ValidUntil) + if err != nil { + expiresAt, err = time.Parse(time.RFC3339, tokenResponse.ValidUntil) + if err != nil { + return Token{}, fmt.Errorf("%s: parse validUntil timestamp: %w", instanceMetadataErrorPrefix, err) + } + } + + return Token{ + AccessToken: tokenResponse.Token, + ExpiresOn: expiresAt, + RefreshOn: expiresAt, + }, nil +} diff --git a/core/identity/instance_metadata_provider_test.go b/core/identity/instance_metadata_provider_test.go new file mode 100644 index 000000000..450f4851e --- /dev/null +++ b/core/identity/instance_metadata_provider_test.go @@ -0,0 +1,112 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestInstanceMetadataTokenCachesResponse(t *testing.T) { + var requests int64 + tokenJWT, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("create token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": tokenJWT, + "validUntil": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), + }) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected same token on cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +func TestInstanceMetadataTokenRequiresServiceAccountEmail(t *testing.T) { + _, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestInstanceMetadataTokenInvalidValidUntil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "token", + "validUntil": "invalid", + }) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestInstanceMetadataTokenHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/core/identity/log.go b/core/identity/log.go new file mode 100644 index 000000000..72f388c36 --- /dev/null +++ b/core/identity/log.go @@ -0,0 +1,50 @@ +package identity + +import ( + "context" + "log/slog" +) + +var identityLogger *slog.Logger + +// SetLogger sets the slog.Logger used by identity providers. +// Pass nil to disable logging (the default — identity is silent unless opted in). +// +// Example: +// +// identity.SetLogger(slog.Default()) +func SetLogger(l *slog.Logger) { + identityLogger = l +} + +// DebugContext emits a Debug-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func DebugContext(ctx context.Context, msg string, args ...any) { + if identityLogger != nil { + identityLogger.DebugContext(ctx, msg, args...) + } +} + +// InfoContext emits an Info-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func InfoContext(ctx context.Context, msg string, args ...any) { + if identityLogger != nil { + identityLogger.InfoContext(ctx, msg, args...) + } +} + +// WarnContext emits a Warn-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func WarnContext(ctx context.Context, msg string, args ...any) { + if identityLogger != nil { + identityLogger.WarnContext(ctx, msg, args...) + } +} + +// ErrorContext emits an Error-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func ErrorContext(ctx context.Context, msg string, args ...any) { + if identityLogger != nil { + identityLogger.ErrorContext(ctx, msg, args...) + } +} diff --git a/core/identity/service_account_key_provider.go b/core/identity/service_account_key_provider.go new file mode 100644 index 000000000..9f54032e3 --- /dev/null +++ b/core/identity/service_account_key_provider.go @@ -0,0 +1,253 @@ +package identity + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + serviceAccountKeyDefaultTokenURL = "https://accounts.stackit.cloud/oauth/v2/token" + serviceAccountJWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + serviceAccountKeyDefaultLeeway = time.Minute + serviceAccountKeyErrorPrefix = "service account key provider" +) + +// ServiceAccountKeyProviderConfig contains the configuration for ServiceAccountKeyProvider. +type ServiceAccountKeyProviderConfig struct { + // ServiceAccountKey is the service account key as a JSON string. + // If empty, attempts to read from STACKIT_SERVICE_ACCOUNT_KEY environment variable, + // then from STACKIT_SERVICE_ACCOUNT_KEY_PATH, then from credentials file. + ServiceAccountKey string + // PrivateKey is the RSA private key as a PEM-encoded string. + // If empty, attempts to read from STACKIT_PRIVATE_KEY environment variable, + // then from STACKIT_PRIVATE_KEY_PATH, then from credentials file. + PrivateKey string + // TokenURL overrides the token endpoint. If empty, the value from the service account key is used, + // falling back to the default STACKIT token endpoint. + TokenURL string + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client +} + +var _ TokenProvider = (*ServiceAccountKeyProvider)(nil) + +// ServiceAccountKey retrieves tokens using STACKIT service account key flow. +type ServiceAccountKeyProvider struct { + name string + httpClient *http.Client + tokenURL string + json *ServiceAccountJson + privateKey *rsa.PrivateKey + + tokenLeeway time.Duration + tokenMutex sync.RWMutex + token Token +} + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// NewServiceAccountKeyProvider creates a ServiceAccountKey provider. +// It resolves configuration from environment variables if not provided in the config. +func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*ServiceAccountKeyProvider, error) { + // Resolve service account key from config, env vars, or credentials file + serviceAccountKeyJSON := cfg.ServiceAccountKey + if serviceAccountKeyJSON == "" { + // Try STACKIT_SERVICE_ACCOUNT_KEY env var + if key, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_KEY"); exists && key != "" { + serviceAccountKeyJSON = key + } else if keyPath, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_KEY_PATH"); exists && keyPath != "" { + // Try STACKIT_SERVICE_ACCOUNT_KEY_PATH env var + data, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("%s: read service account key from path: %w", serviceAccountKeyErrorPrefix, err) + } + serviceAccountKeyJSON = string(data) + } + } + + if serviceAccountKeyJSON == "" { + return nil, fmt.Errorf("%s: service account key not provided and not found in environment variables", serviceAccountKeyErrorPrefix) + } + + // Parse service account key JSON + var serviceAccountKey = &ServiceAccountJson{} + if err := json.Unmarshal([]byte(serviceAccountKeyJSON), serviceAccountKey); err != nil { + return nil, fmt.Errorf("%s: parse service account key JSON: %w", serviceAccountKeyErrorPrefix, err) + } + + if serviceAccountKey.Credentials == nil { + return nil, fmt.Errorf("%s: service account key credentials cannot be empty", serviceAccountKeyErrorPrefix) + } + + // Resolve private key from config, env vars, credentials file, or embedded in service account key + privateKeyPEM := cfg.PrivateKey + if privateKeyPEM == "" { + // Try STACKIT_PRIVATE_KEY env var + if key, exists := os.LookupEnv("STACKIT_PRIVATE_KEY"); exists && key != "" { + privateKeyPEM = key + } else if keyPath, exists := os.LookupEnv("STACKIT_PRIVATE_KEY_PATH"); exists && keyPath != "" { + // Try STACKIT_PRIVATE_KEY_PATH env var + data, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("%s: read private key from path: %w", serviceAccountKeyErrorPrefix, err) + } + privateKeyPEM = string(data) + } else if serviceAccountKey.Credentials.PrivateKey != nil { + // Fall back to private key embedded in service account key + privateKeyPEM = *serviceAccountKey.Credentials.PrivateKey + } + } + + if privateKeyPEM == "" { + return nil, fmt.Errorf("%s: private key cannot be empty", serviceAccountKeyErrorPrefix) + } + + // Parse RSA private key + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM)) + if err != nil { + return nil, fmt.Errorf("%s: parse private key from PEM: %w", serviceAccountKeyErrorPrefix, err) + } + + // Resolve token URL + tokenURL := cfg.TokenURL + if tokenURL == "" { + tokenURL = serviceAccountKey.Credentials.TokenEndpoint + } + if tokenURL == "" { + tokenURL = serviceAccountKeyDefaultTokenURL + } + + // Resolve HTTP client + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + + // Resolve token refresh leeway + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = serviceAccountKeyDefaultLeeway + } + + return &ServiceAccountKeyProvider{ + name: "ServiceAccountKeyProvider", + httpClient: httpClient, + tokenURL: tokenURL, + json: serviceAccountKey, + privateKey: privateKey, + tokenLeeway: leeway, + }, nil +} + +// Token returns a valid access token. +func (p *ServiceAccountKeyProvider) Token(ctx context.Context, _ TokenRequestOptions) (Token, error) { + if p == nil || p.httpClient == nil { + return Token{}, fmt.Errorf("%s: provider is not initialized", serviceAccountKeyErrorPrefix) + } + if ctx == nil { + ctx = context.Background() + } + + now := time.Now().Add(p.tokenLeeway) + p.tokenMutex.RLock() + cached := p.token + p.tokenMutex.RUnlock() + if cached.AccessToken != "" && cached.RefreshOn.After(now) { + return cached, nil + } + + fresh, err := p.requestToken(ctx) + if err != nil { + return Token{}, err + } + DebugContext(ctx, "identity: authenticated", "provider", p.name) + p.tokenMutex.Lock() + p.token = fresh + p.tokenMutex.Unlock() + return fresh, nil +} + +func (p *ServiceAccountKeyProvider) requestToken(ctx context.Context) (Token, error) { + assertion, err := p.generateSelfSignedJWT() + if err != nil { + return Token{}, err + } + + body := url.Values{} + body.Set("grant_type", serviceAccountJWTBearerGrantType) + body.Set("assertion", assertion) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) + if err != nil { + return Token{}, fmt.Errorf("%s: create token request: %w", serviceAccountKeyErrorPrefix, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request access token: %w", serviceAccountKeyErrorPrefix, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + bodyRaw, _ := io.ReadAll(res.Body) + return Token{}, fmt.Errorf("%s: token request failed with status %d: %s", serviceAccountKeyErrorPrefix, res.StatusCode, string(bodyRaw)) + } + + var tokenResponse oauthTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode token response: %w", serviceAccountKeyErrorPrefix, err) + } + if tokenResponse.AccessToken == "" { + return Token{}, fmt.Errorf("%s: token response did not include access token", serviceAccountKeyErrorPrefix) + } + + expiresOn, err := getTokenExpiration(tokenResponse.AccessToken, tokenResponse.ExpiresIn) + if err != nil { + return Token{}, fmt.Errorf("%s: %w", serviceAccountKeyErrorPrefix, err) + } + + refreshOn := expiresOn.Add(-p.tokenLeeway) + return Token{ + AccessToken: tokenResponse.AccessToken, + ExpiresOn: expiresOn, + RefreshOn: refreshOn, + }, nil +} + +func (p *ServiceAccountKeyProvider) generateSelfSignedJWT() (string, error) { + claims := jwt.MapClaims{ + "iss": p.json.Credentials.Iss, + "sub": p.json.Credentials.Sub, + "jti": uuid.New(), + "aud": p.json.Credentials.Aud, + "iat": jwt.NewNumericDate(time.Now()), + "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims) + token.Header["kid"] = p.json.Credentials.Kid + + tokenString, err := token.SignedString(p.privateKey) + if err != nil { + return "", fmt.Errorf("%s: sign self-signed jwt: %w", serviceAccountKeyErrorPrefix, err) + } + return tokenString, nil +} diff --git a/core/identity/service_account_key_provider_test.go b/core/identity/service_account_key_provider_test.go new file mode 100644 index 000000000..84b4f6778 --- /dev/null +++ b/core/identity/service_account_key_provider_test.go @@ -0,0 +1,107 @@ +package identity + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func TestServiceAccountKeyToken(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var requests int64 + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.PostForm.Get("grant_type") != serviceAccountJWTBearerGrantType { + t.Fatalf("unexpected grant type: %s", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("assertion") == "" { + t.Fatalf("expected assertion") + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + // Create service account key as JSON + saKey := ServiceAccountJson{ + Credentials: &ServiceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +func TestServiceAccountKeyRequiresKeyAndPrivateKey(t *testing.T) { + _, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func generateRSAPrivateKeyPEM() ([]byte, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), nil +} diff --git a/core/identity/static_token_provider.go b/core/identity/static_token_provider.go new file mode 100644 index 000000000..5cdb04ed2 --- /dev/null +++ b/core/identity/static_token_provider.go @@ -0,0 +1,56 @@ +package identity + +import ( + "context" + "fmt" + "os" +) + +var _ TokenProvider = (*StaticTokenProvider)(nil) + +// StaticTokenProviderConfig contains configuration for StaticTokenProvider. +type StaticTokenProviderConfig struct { + // Token is the static access token. If empty, will attempt to resolve from STACKIT_SERVICE_ACCOUNT_TOKEN env var. + Token string +} + +// StaticTokenProvider provides a static access token. +type StaticTokenProvider struct { + name string + token Token +} + +// NewStaticTokenProvider creates a StaticTokenProvider, resolving the token from config or environment. +func NewStaticTokenProvider(cfg StaticTokenProviderConfig) (*StaticTokenProvider, error) { + token := cfg.Token + if token == "" { + token, _ = os.LookupEnv("STACKIT_SERVICE_ACCOUNT_TOKEN") + } + if token == "" { + return nil, fmt.Errorf("static token provider: STACKIT_SERVICE_ACCOUNT_TOKEN not set and no token provided in config") + } + + expiresOn, err := getTokenExpiration(token, 0) + if err != nil { + return nil, fmt.Errorf("static token expiration invalid: %w", err) + } + + accessToken := Token{ + AccessToken: token, + ExpiresOn: expiresOn, + RefreshOn: expiresOn, + } + + return &StaticTokenProvider{ + name: "StaticTokenProvider", + token: accessToken, + }, nil +} + +// Token returns the static access token, parsing its expiration from the JWT claims. +func (p *StaticTokenProvider) Token(_ context.Context, _ TokenRequestOptions) (Token, error) { + if p.token.AccessToken == "" { + return Token{}, fmt.Errorf("static token is empty") + } + return p.token, nil +} diff --git a/core/identity/static_token_provider_test.go b/core/identity/static_token_provider_test.go new file mode 100644 index 000000000..fefb22614 --- /dev/null +++ b/core/identity/static_token_provider_test.go @@ -0,0 +1,34 @@ +package identity + +import ( + "context" + "testing" +) + +func TestStaticTokenProvider(t *testing.T) { + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + provider, err := NewStaticTokenProvider(StaticTokenProviderConfig{Token: accessToken}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if token.AccessToken != accessToken { + t.Fatalf("expected %s, got %s", accessToken, token.AccessToken) + } + + if token.ExpiresOn.IsZero() { + t.Fatalf("expected non-zero expiration") + } +} + +func TestStaticTokenProviderEmpty(t *testing.T) { + _, err := NewStaticTokenProvider(StaticTokenProviderConfig{Token: ""}) + if err == nil { + t.Fatalf("expected error for empty token") + } +} diff --git a/core/identity/token.go b/core/identity/token.go new file mode 100644 index 000000000..75559ed00 --- /dev/null +++ b/core/identity/token.go @@ -0,0 +1,27 @@ +package identity + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// getTokenExpiration extracts the expiration time from a JWT token. +// If expiresIn is provided (> 0), it's used to calculate expiration as Now + expiresIn seconds. +// Otherwise, the exp claim is parsed from the token. +func getTokenExpiration(accessToken string, expiresIn int) (time.Time, error) { + if expiresIn > 0 { + return time.Now().Add(time.Duration(expiresIn) * time.Second), nil + } + + parsed, _, err := jwt.NewParser().ParseUnverified(accessToken, &jwt.RegisteredClaims{}) + if err != nil { + return time.Time{}, fmt.Errorf("parse access token: %w", err) + } + exp, err := parsed.Claims.GetExpirationTime() + if err != nil { + return time.Time{}, fmt.Errorf("get token expiration: %w", err) + } + return exp.Time, nil +} diff --git a/core/identity/types.go b/core/identity/types.go new file mode 100644 index 000000000..b8e2a6445 --- /dev/null +++ b/core/identity/types.go @@ -0,0 +1,30 @@ +package identity + +import ( + "time" + + "github.com/google/uuid" +) + +// ServiceAccountJson is the API response when creating a STACKIT service account key. +type ServiceAccountJson struct { + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + Credentials *ServiceAccountKeyCredentials `json:"credentials"` + ID uuid.UUID `json:"id"` + KeyAlgorithm string `json:"keyAlgorithm"` + KeyOrigin string `json:"keyOrigin"` + KeyType string `json:"keyType"` + PublicKey string `json:"publicKey"` + ValidUntil *time.Time `json:"validUntil,omitempty"` +} + +// ServiceAccountKeyCredentials contains the credential fields embedded in a ServiceAccountKeyResponse. +type ServiceAccountKeyCredentials struct { + Aud string `json:"aud"` + Iss string `json:"iss"` + Kid string `json:"kid"` + PrivateKey *string `json:"privateKey,omitempty"` + Sub uuid.UUID `json:"sub"` + TokenEndpoint string `json:"tokenEndpoint"` +} diff --git a/core/identity/workload_identity_federation_provider.go b/core/identity/workload_identity_federation_provider.go new file mode 100644 index 000000000..08b8bfdf1 --- /dev/null +++ b/core/identity/workload_identity_federation_provider.go @@ -0,0 +1,184 @@ +package identity + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" + "github.com/stackitcloud/stackit-sdk-go/core/utils" +) + +const ( + workloadIdentityClientIDEnv = "STACKIT_SERVICE_ACCOUNT_EMAIL" + workloadIdentityFederatedTokenEnv = "STACKIT_FEDERATED_TOKEN_FILE" + workloadIdentityTokenEndpointEnv = "STACKIT_IDP_TOKEN_ENDPOINT" + workloadIdentityDefaultTokenURL = "https://accounts.stackit.cloud/oauth/v2/token" + workloadIdentityDefaultTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" + workloadIdentityClientAssertionType = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" + workloadIdentityGrantType = "client_credentials" + workloadIdentityDefaultLeeway = time.Minute + workloadIdentityErrorPrefix = "workload identity federation provider" +) + +// WorkloadIdentityFederationProviderConfig contains the configuration for WorkloadIdentityFederationProvider. +type WorkloadIdentityFederationProviderConfig struct { + // TokenURL overrides the token endpoint. If empty, STACKIT_IDP_TOKEN_ENDPOINT env var is used, + // falling back to the default STACKIT WIF token endpoint. + TokenURL string + // ClientID is the service account email. If empty, STACKIT_SERVICE_ACCOUNT_EMAIL env var is used. + ClientID string + // FederatedTokenFunction provides the OIDC assertion token. If nil, the default filesystem reader is used. + FederatedTokenFunction oidcadapters.OIDCTokenFunc + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client +} + +var _ TokenProvider = (*WorkloadIdentityFederationProvider)(nil) + +// WorkloadIdentityFederation retrieves tokens using WIF flow. +type WorkloadIdentityFederationProvider struct { + name string + httpClient *http.Client + tokenURL string + clientID string + federatedTokenFunction oidcadapters.OIDCTokenFunc + tokenLeeway time.Duration + tokenMutex sync.RWMutex + token Token +} + +type workloadIdentityTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// NewWorkloadIdentityFederationProvider creates a WorkloadIdentityFederation provider. +func NewWorkloadIdentityFederationProvider(cfg WorkloadIdentityFederationProviderConfig) (*WorkloadIdentityFederationProvider, error) { + tokenURL := cfg.TokenURL + if tokenURL == "" { + tokenURL = utils.GetEnvOrDefault(workloadIdentityTokenEndpointEnv, workloadIdentityDefaultTokenURL) + } + + clientID := cfg.ClientID + if clientID == "" { + clientID = utils.GetEnvOrDefault(workloadIdentityClientIDEnv, "") + } + if clientID == "" { + return nil, fmt.Errorf("%s: client ID cannot be empty", workloadIdentityErrorPrefix) + } + + federatedTokenFunction := cfg.FederatedTokenFunction + if federatedTokenFunction == nil { + federatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(workloadIdentityFederatedTokenEnv, workloadIdentityDefaultTokenPath)) + } + if _, err := federatedTokenFunction(context.Background()); err != nil { + return nil, fmt.Errorf("%s: error reading federated token file: %w", workloadIdentityErrorPrefix, err) + } + + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = workloadIdentityDefaultLeeway + } + + return &WorkloadIdentityFederationProvider{ + name: "WorkloadIdentityFederationProvider", + httpClient: httpClient, + tokenURL: tokenURL, + clientID: clientID, + federatedTokenFunction: federatedTokenFunction, + tokenLeeway: leeway, + }, nil +} + +// Token returns a valid access token. +func (p *WorkloadIdentityFederationProvider) Token(ctx context.Context, _ TokenRequestOptions) (Token, error) { + if p == nil || p.httpClient == nil { + return Token{}, fmt.Errorf("%s: provider is not initialized", workloadIdentityErrorPrefix) + } + if ctx == nil { + ctx = context.Background() + } + + now := time.Now().Add(p.tokenLeeway) + p.tokenMutex.RLock() + cached := p.token + p.tokenMutex.RUnlock() + if cached.AccessToken != "" && cached.RefreshOn.After(now) { + return cached, nil + } + + fresh, err := p.requestToken(ctx) + if err != nil { + return Token{}, err + } + DebugContext(ctx, "identity: authenticated", "provider", p.name) + p.tokenMutex.Lock() + p.token = fresh + p.tokenMutex.Unlock() + return fresh, nil +} + +func (p *WorkloadIdentityFederationProvider) requestToken(ctx context.Context) (Token, error) { + clientAssertion, err := p.federatedTokenFunction(ctx) + if err != nil { + return Token{}, fmt.Errorf("%s: get federated token: %w", workloadIdentityErrorPrefix, err) + } + + body := url.Values{} + body.Set("grant_type", workloadIdentityGrantType) + body.Set("client_assertion_type", workloadIdentityClientAssertionType) + body.Set("client_assertion", clientAssertion) + body.Set("client_id", p.clientID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) + if err != nil { + return Token{}, fmt.Errorf("%s: create token request: %w", workloadIdentityErrorPrefix, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request access token: %w", workloadIdentityErrorPrefix, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + bodyRaw, _ := io.ReadAll(res.Body) + return Token{}, fmt.Errorf("%s: token request failed with status %d: %s", workloadIdentityErrorPrefix, res.StatusCode, string(bodyRaw)) + } + + var tokenResponse workloadIdentityTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode token response: %w", workloadIdentityErrorPrefix, err) + } + if tokenResponse.AccessToken == "" { + return Token{}, fmt.Errorf("%s: token response did not include access token", workloadIdentityErrorPrefix) + } + + expiresOn, err := getTokenExpiration(tokenResponse.AccessToken, tokenResponse.ExpiresIn) + if err != nil { + return Token{}, err + } + + refreshOn := expiresOn.Add(-p.tokenLeeway) + return Token{ + AccessToken: tokenResponse.AccessToken, + ExpiresOn: expiresOn, + RefreshOn: refreshOn, + }, nil +} diff --git a/core/identity/workload_identity_federation_provider_test.go b/core/identity/workload_identity_federation_provider_test.go new file mode 100644 index 000000000..1d4d7b097 --- /dev/null +++ b/core/identity/workload_identity_federation_provider_test.go @@ -0,0 +1,90 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestWorkloadIdentityFederationToken(t *testing.T) { + assertion, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "assertion", + }).SignedString([]byte("assertion-secret")) + if err != nil { + t.Fatalf("generate assertion token: %v", err) + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("access-secret")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + var requests int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.PostForm.Get("grant_type") != workloadIdentityGrantType { + t.Fatalf("unexpected grant type: %s", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("client_assertion_type") != workloadIdentityClientAssertionType { + t.Fatalf("unexpected assertion type: %s", r.PostForm.Get("client_assertion_type")) + } + if r.PostForm.Get("client_assertion") == "" { + t.Fatalf("expected client assertion") + } + if r.PostForm.Get("client_id") != "service-account@sa.stackit.cloud" { + t.Fatalf("unexpected client id: %s", r.PostForm.Get("client_id")) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + provider, err := NewWorkloadIdentityFederationProvider(WorkloadIdentityFederationProviderConfig{ + TokenURL: server.URL, + ClientID: "service-account@sa.stackit.cloud", + FederatedTokenFunction: func(context.Context) (string, error) { return assertion, nil }, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +func TestWorkloadIdentityFederationRequiresClientID(t *testing.T) { + _, err := NewWorkloadIdentityFederationProvider(WorkloadIdentityFederationProviderConfig{ + FederatedTokenFunction: func(context.Context) (string, error) { return "token", nil }, + }) + if err == nil { + t.Fatalf("expected error") + } +} From 3c3630a009acaee448c6fd02d00bfb30f6f4f7c0 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 9 Jul 2026 23:26:47 +0200 Subject: [PATCH 2/4] refactors Signed-off-by: Jorge Turrado --- core/auth/auth.go | 176 +++-------- core/auth/auth_test.go | 22 +- core/clients/continuous_refresh_test.go | 3 +- core/clients/key_flow.go | 20 +- core/clients/key_flow_test.go | 3 +- core/clients/token_flow.go | 10 +- core/clients/workload_identity_flow.go | 28 +- core/config/config.go | 6 + core/identity/auth_constants.go | 25 ++ core/identity/credentials.go | 97 ++++++ core/identity/env_vars.go | 21 ++ core/identity/http_client.go | 29 ++ core/identity/instance_metadata_provider.go | 6 +- core/identity/service_account_key_provider.go | 59 +++- .../service_account_key_provider_test.go | 275 ++++++++++++++++++ core/identity/static_token_provider.go | 24 +- .../workload_identity_federation_provider.go | 39 +-- ...kload_identity_federation_provider_test.go | 4 +- 18 files changed, 613 insertions(+), 234 deletions(-) create mode 100644 core/identity/auth_constants.go create mode 100644 core/identity/credentials.go create mode 100644 core/identity/env_vars.go create mode 100644 core/identity/http_client.go diff --git a/core/auth/auth.go b/core/auth/auth.go index 8629108b7..3d97cd2f4 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -5,36 +5,12 @@ import ( "fmt" "net/http" "os" - "path/filepath" - "time" "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/identity" ) -type credentialType string - -type Credentials struct { - STACKIT_SERVICE_ACCOUNT_EMAIL string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. - STACKIT_SERVICE_ACCOUNT_TOKEN string - STACKIT_SERVICE_ACCOUNT_KEY_PATH string - STACKIT_PRIVATE_KEY_PATH string - STACKIT_SERVICE_ACCOUNT_KEY string - STACKIT_PRIVATE_KEY string -} - -const ( - credentialsFilePath = ".stackit/credentials.json" //nolint:gosec // linter false positive - tokenCredentialType credentialType = "token" - serviceAccountKeyCredentialType credentialType = "service_account_key" - serviceAccountKeyPathCredentialType credentialType = "service_account_key_path" - privateKeyCredentialType credentialType = "private_key" - privateKeyPathCredentialType credentialType = "private_key_path" -) - -var userHomeDir = os.UserHomeDir - // SetupAuth sets up authentication based on the configuration. The different options are // custom authentication, no authentication, explicit key flow, explicit token flow or default authentication func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { @@ -98,60 +74,45 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { // Try Instance Metadata Service (IMS) with aggressive timeout to fail fast if not in cloud email := getServiceAccountEmail(cfg) if email != "" { - imsClient := &http.Client{Timeout: 2 * time.Second} if imsProvider, err := identity.NewInstanceMetadataProvider(identity.InstanceMetadataProviderConfig{ ServiceAccountEmail: email, - HTTPClient: imsClient, + HTTPClient: cfg.HTTPClient, }); err == nil { providers = append(providers, imsProvider) } } - // Try Workload Identity Federation + // Try Workload Identity Federation - provider handles client cleanup internally if wifProvider, err := identity.NewWorkloadIdentityFederationProvider(identity.WorkloadIdentityFederationProviderConfig{ TokenURL: cfg.TokenCustomUrl, ClientID: cfg.ServiceAccountEmail, FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, HTTPClient: cfg.HTTPClient, + Scopes: cfg.Scopes, + Resources: cfg.Resources, }); err == nil { providers = append(providers, wifProvider) } - // Try Service Account Key - provider handles env var resolution, fallback to credentials file + // Try Service Account Key - provider handles client cleanup internally and credentials file resolution if keyProvider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ - ServiceAccountKey: cfg.ServiceAccountKey, - PrivateKey: cfg.PrivateKey, - TokenURL: cfg.TokenCustomUrl, - HTTPClient: cfg.HTTPClient, + ServiceAccountKey: cfg.ServiceAccountKey, + PrivateKey: cfg.PrivateKey, + TokenURL: cfg.TokenCustomUrl, + HTTPClient: cfg.HTTPClient, + CredentialsFilePath: &cfg.CredentialsFilePath, + Scopes: cfg.Scopes, + Resources: cfg.Resources, }); err == nil { providers = append(providers, keyProvider) - } else { - // Try resolving from credentials file if direct/env resolution fails - keyCfg := *cfg - if getServiceAccountKeyAndPrivateKeyWithFallback(&keyCfg) == nil { - if keyProvider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ - ServiceAccountKey: keyCfg.ServiceAccountKey, - PrivateKey: keyCfg.PrivateKey, - TokenURL: keyCfg.TokenCustomUrl, - HTTPClient: keyCfg.HTTPClient, - }); err == nil { - providers = append(providers, keyProvider) - } - } } - // Try Static Token - provider handles env var resolution, fallback to credentials file + // Try Static Token - provider handles env var resolution and credentials file resolution if staticProvider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ - Token: cfg.Token, + Token: cfg.Token, + CredentialsFilePath: &cfg.CredentialsFilePath, }); err == nil { providers = append(providers, staticProvider) - } else if token, err := getTokenWithFallback(cfg); err == nil && token != "" { - // Try resolving from credentials file if env resolution fails - if staticProvider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ - Token: token, - }); err == nil { - providers = append(providers, staticProvider) - } } chained, err := identity.NewChainedProvider(providers...) @@ -189,6 +150,10 @@ func NoAuth(cfgs ...*config.Configuration) (rt http.RoundTripper, err error) { // TokenAuth configures the token flow and returns an http.RoundTripper // that can be used to make authenticated requests using a token func TokenAuth(cfg *config.Configuration) (http.RoundTripper, error) { + if cfg == nil { + cfg = &config.Configuration{} + } + token, err := getTokenWithFallback(cfg) if err != nil { return nil, fmt.Errorf("resolving token: %w", err) @@ -198,7 +163,8 @@ func TokenAuth(cfg *config.Configuration) (http.RoundTripper, error) { } provider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ - Token: token, + Token: token, + CredentialsFilePath: &cfg.CredentialsFilePath, }) if err != nil { return nil, fmt.Errorf("error initializing static token provider: %w", err) @@ -226,10 +192,11 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) { } provider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ - ServiceAccountKey: cfg.ServiceAccountKey, - PrivateKey: cfg.PrivateKey, - TokenURL: cfg.TokenCustomUrl, - HTTPClient: cfg.HTTPClient, + ServiceAccountKey: cfg.ServiceAccountKey, + PrivateKey: cfg.PrivateKey, + TokenURL: cfg.TokenCustomUrl, + HTTPClient: cfg.HTTPClient, + CredentialsFilePath: &cfg.CredentialsFilePath, }) if err != nil { return nil, fmt.Errorf("error initializing client: %w", err) @@ -261,71 +228,6 @@ func getTransportFromConfig(cfg *config.Configuration) http.RoundTripper { return nil } -// readCredentialsFile reads the credentials file from the specified path and returns Credentials -func readCredentialsFile(path string) (*Credentials, error) { - if path == "" { - customPath, customPathSet := os.LookupEnv("STACKIT_CREDENTIALS_PATH") - if !customPathSet || customPath == "" { - path = credentialsFilePath - home, err := userHomeDir() - if err != nil { - return nil, fmt.Errorf("getting home directory: %w", err) - } - path = filepath.Join(home, path) - } else { - path = customPath - } - } - - credentialsRaw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("opening file: %w", err) - } - - var credentials Credentials - err = json.Unmarshal(credentialsRaw, &credentials) - if err != nil { - return nil, fmt.Errorf("unmarshalling credentials: %w", err) - } - return &credentials, nil -} - -// readCredential reads the specified credentialType from Credentials and returns it as a string -func readCredential(cred credentialType, credentials *Credentials) (string, error) { - var credentialValue string - switch cred { - case tokenCredentialType: - credentialValue = credentials.STACKIT_SERVICE_ACCOUNT_TOKEN - if credentialValue == "" { - return credentialValue, fmt.Errorf("token is empty or not set") - } - case serviceAccountKeyPathCredentialType: - credentialValue = credentials.STACKIT_SERVICE_ACCOUNT_KEY_PATH - if credentialValue == "" { - return credentialValue, fmt.Errorf("service account key path is empty or not set") - } - case privateKeyPathCredentialType: - credentialValue = credentials.STACKIT_PRIVATE_KEY_PATH - if credentialValue == "" { - return credentialValue, fmt.Errorf("private key path is empty or not set") - } - case serviceAccountKeyCredentialType: - credentialValue = credentials.STACKIT_SERVICE_ACCOUNT_KEY - if credentialValue == "" { - return credentialValue, fmt.Errorf("service account key is empty or not set") - } - case privateKeyCredentialType: - credentialValue = credentials.STACKIT_PRIVATE_KEY - if credentialValue == "" { - return credentialValue, fmt.Errorf("private key is empty or not set") - } - default: - return "", fmt.Errorf("invalid credential type: %s", cred) - } - - return credentialValue, nil -} - // getServiceAccountEmail searches for an email in the following order: client configuration, environment variable, credentials file. // is not required for authentication, so it can be empty. func getServiceAccountEmail(cfg *config.Configuration) string { @@ -333,20 +235,20 @@ func getServiceAccountEmail(cfg *config.Configuration) string { return cfg.ServiceAccountEmail } - email, emailSet := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_EMAIL") + email, emailSet := os.LookupEnv(identity.EnvServiceAccountEmail) if !emailSet || email == "" { - credentials, err := readCredentialsFile(cfg.CredentialsFilePath) + credentials, err := identity.ReadCredentialsFile(cfg.CredentialsFilePath) if err != nil { // email is not required for authentication, so it shouldnt block it return "" } - return credentials.STACKIT_SERVICE_ACCOUNT_EMAIL + return credentials.ServiceAccountEmail } return email } // getKey searches for a key in the following order: client configuration, environment variable, credentials file. -func getKey(cfgKey, cfgKeyPath *string, envVarKeyPath, envVarKey string, credTypePath, credTypeKey credentialType, cfgCredFilePath string) error { +func getKey(cfgKey, cfgKeyPath *string, envVarKeyPath, envVarKey string, credTypePath, credTypeKey identity.CredentialType, cfgCredFilePath string) error { if *cfgKey != "" { return nil } @@ -357,15 +259,15 @@ func getKey(cfgKey, cfgKeyPath *string, envVarKeyPath, envVarKey string, credTyp key, keySet := os.LookupEnv(envVarKey) // if both are not set -> read from credentials file if (!keyPathSet || keyPath == "") && (!keySet || key == "") { - credentials, err := readCredentialsFile(cfgCredFilePath) + credentials, err := identity.ReadCredentialsFile(cfgCredFilePath) if err != nil { return fmt.Errorf("reading from credentials file: %w", err) } // read key path from credentials file - keyPath, err = readCredential(credTypePath, credentials) + keyPath, err = identity.ReadCredential(credTypePath, credentials) if err != nil || keyPath == "" { // key path was not provided, read key from credentials file - key, err = readCredential(credTypeKey, credentials) + key, err = identity.ReadCredential(credTypeKey, credentials) if err != nil || key == "" { return fmt.Errorf("neither key nor path is provided in the configuration, environment variable, or credentials file: %w", err) } @@ -392,12 +294,12 @@ func getKey(cfgKey, cfgKeyPath *string, envVarKeyPath, envVarKey string, credTyp // getServiceAccountKey configures the service account key in the provided configuration func getServiceAccountKey(cfg *config.Configuration) error { - return getKey(&cfg.ServiceAccountKey, &cfg.ServiceAccountKeyPath, "STACKIT_SERVICE_ACCOUNT_KEY_PATH", "STACKIT_SERVICE_ACCOUNT_KEY", serviceAccountKeyPathCredentialType, serviceAccountKeyCredentialType, cfg.CredentialsFilePath) + return getKey(&cfg.ServiceAccountKey, &cfg.ServiceAccountKeyPath, identity.EnvServiceAccountKeyPath, identity.EnvServiceAccountKey, identity.CredentialTypeServiceAccountKeyPath, identity.CredentialTypeServiceAccountKey, cfg.CredentialsFilePath) } // getPrivateKey configures the private key in the provided configuration func getPrivateKey(cfg *config.Configuration) error { - return getKey(&cfg.PrivateKey, &cfg.PrivateKeyPath, "STACKIT_PRIVATE_KEY_PATH", "STACKIT_PRIVATE_KEY", privateKeyPathCredentialType, privateKeyCredentialType, cfg.CredentialsFilePath) + return getKey(&cfg.PrivateKey, &cfg.PrivateKeyPath, identity.EnvPrivateKeyPath, identity.EnvPrivateKey, identity.CredentialTypePrivateKeyPath, identity.CredentialTypePrivateKey, cfg.CredentialsFilePath) } // getTokenWithFallback resolves a token from config, environment, or credentials file @@ -407,17 +309,17 @@ func getTokenWithFallback(cfg *config.Configuration) (string, error) { } // Try environment variable - if token, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_TOKEN"); exists && token != "" { + if token, exists := os.LookupEnv(identity.EnvServiceAccountToken); exists && token != "" { return token, nil } // Try credentials file - credentials, err := readCredentialsFile(cfg.CredentialsFilePath) + credentials, err := identity.ReadCredentialsFile(cfg.CredentialsFilePath) if err != nil { return "", fmt.Errorf("reading from credentials file: %w", err) } - token, err := readCredential(tokenCredentialType, credentials) + token, err := identity.ReadCredential(identity.CredentialTypeToken, credentials) if err != nil { return "", fmt.Errorf("token not found in credentials file: %w", err) } @@ -453,7 +355,7 @@ func getServiceAccountKeyAndPrivateKeyWithFallback(cfg *config.Configuration) er // Resolve token URL from env if not set if cfg.TokenCustomUrl == "" { - if tokenURL, exists := os.LookupEnv("STACKIT_TOKEN_BASEURL"); exists && tokenURL != "" { + if tokenURL, exists := os.LookupEnv(identity.EnvTokenBaseUrl); exists && tokenURL != "" { cfg.TokenCustomUrl = tokenURL } } diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 2c087fd13..6705adf29 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -20,11 +20,11 @@ import ( ) func setTemporaryHome(t *testing.T) { - old := userHomeDir + old := identity.UserHomeDir t.Cleanup(func() { - userHomeDir = old + identity.UserHomeDir = old }) - userHomeDir = func() (string, error) { + identity.UserHomeDir = func() (string, error) { return t.TempDir(), nil } } @@ -353,7 +353,7 @@ func TestReadCredentials(t *testing.T) { desc string path string pathEnv string - credentialType credentialType + credentialType identity.CredentialType isValid bool expectedCredential string }{ @@ -361,7 +361,7 @@ func TestReadCredentials(t *testing.T) { desc: "valid_path_argument_token", path: "test_resources/test_credentials_bar.json", pathEnv: "", - credentialType: tokenCredentialType, + credentialType: identity.CredentialTypeToken, isValid: true, expectedCredential: "bar_token", }, @@ -369,7 +369,7 @@ func TestReadCredentials(t *testing.T) { desc: "valid_path_env", path: "", pathEnv: "test_resources/test_credentials_bar.json", - credentialType: tokenCredentialType, + credentialType: identity.CredentialTypeToken, isValid: true, expectedCredential: "bar_token", }, @@ -377,14 +377,14 @@ func TestReadCredentials(t *testing.T) { desc: "path_over_env", path: "test_resources/test_credentials_bar.json", pathEnv: "test_resources/test_credentials_foo.json", - credentialType: tokenCredentialType, + credentialType: identity.CredentialTypeToken, isValid: true, expectedCredential: "bar_token", }, { desc: "invalid_structure", path: "test_resources/test_invalid_structure.json", - credentialType: tokenCredentialType, + credentialType: identity.CredentialTypeToken, pathEnv: "", isValid: false, expectedCredential: "", @@ -395,9 +395,9 @@ func TestReadCredentials(t *testing.T) { t.Setenv("STACKIT_CREDENTIALS_PATH", test.pathEnv) var credential string - credentials, err := readCredentialsFile(test.path) + credentials, err := identity.ReadCredentialsFile(test.path) if err == nil { - credential, err = readCredential(test.credentialType, credentials) + credential, err = identity.ReadCredential(test.credentialType, credentials) } if err != nil && test.isValid { @@ -418,7 +418,7 @@ func TestReadCredentials(t *testing.T) { func TestReadCredentialsFileErrorMessage(t *testing.T) { setTemporaryHome(t) - _, err := readCredentialsFile("test_resources/test_invalid_structure.json") + _, err := identity.ReadCredentialsFile("test_resources/test_invalid_structure.json") if err == nil { t.Fatalf("error expected") } diff --git a/core/clients/continuous_refresh_test.go b/core/clients/continuous_refresh_test.go index 311ac6ba1..72eb9eaa3 100644 --- a/core/clients/continuous_refresh_test.go +++ b/core/clients/continuous_refresh_test.go @@ -14,6 +14,7 @@ import ( "github.com/golang-jwt/jwt/v5" + "github.com/stackitcloud/stackit-sdk-go/core/identity" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" ) @@ -187,7 +188,7 @@ func TestContinuousRefreshTokenConcurrency(t *testing.T) { defer cancel() // This cancels the refresher goroutine // Extract host from tokenAPI constant for consistency - tokenURL, _ := url.Parse(tokenAPI) + tokenURL, _ := url.Parse(identity.KeyFlowTokenAPI) tokenHost := tokenURL.Host // The Do() routine, that both the keyFlow and continuousRefreshToken() use to make their requests diff --git a/core/clients/key_flow.go b/core/clients/key_flow.go index 8e3058c26..fdc779934 100644 --- a/core/clients/key_flow.go +++ b/core/clients/key_flow.go @@ -21,18 +21,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/identity" ) -const ( - // Service Account Key Flow - // Auth flow env variables - ServiceAccountKey = "STACKIT_SERVICE_ACCOUNT_KEY" - PrivateKey = "STACKIT_PRIVATE_KEY" - ServiceAccountKeyPath = "STACKIT_SERVICE_ACCOUNT_KEY_PATH" - PrivateKeyPath = "STACKIT_PRIVATE_KEY_PATH" - tokenAPI = "https://service-account.api.stackit.cloud/token" //nolint:gosec // linter false positive - defaultTokenType = "Bearer" - defaultScope = "" -) - var _ AuthFlow = &KeyFlow{} // KeyFlow handles auth with SA key @@ -108,11 +96,11 @@ func (c *KeyFlow) GetToken() TokenResponseBody { // getCredentialsTokenEndpoint returns the token endpoint from credentials or a default fallback func (cfg *KeyFlowConfig) getCredentialsTokenEndpoint() string { if cfg.ServiceAccountKey == nil || cfg.ServiceAccountKey.Credentials == nil { - return tokenAPI + return identity.KeyFlowTokenAPI } if cfg.ServiceAccountKey.Credentials.TokenEndpoint == "" { - return tokenAPI + return identity.KeyFlowTokenAPI } return cfg.ServiceAccountKey.Credentials.TokenEndpoint @@ -172,8 +160,8 @@ func (c *KeyFlow) SetToken(accessToken, refreshToken string) error { AccessToken: accessToken, ExpiresIn: int(exp.Unix()), RefreshToken: refreshToken, - Scope: defaultScope, - TokenType: defaultTokenType, + Scope: identity.DefaultScope, + TokenType: identity.DefaultTokenType, } c.tokenMutex.Unlock() return nil diff --git a/core/clients/key_flow_test.go b/core/clients/key_flow_test.go index be25e7d89..00425d155 100644 --- a/core/clients/key_flow_test.go +++ b/core/clients/key_flow_test.go @@ -20,6 +20,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/core/identity" ) var ( @@ -83,7 +84,7 @@ func TestKeyFlowInit(t *testing.T) { serviceAccountKey: fixtureServiceAccountKey(), genPrivateKey: true, wantErr: false, - wantTokenUrl: tokenAPI, + wantTokenUrl: identity.KeyFlowTokenAPI, }, { name: "missing_private_key", diff --git a/core/clients/token_flow.go b/core/clients/token_flow.go index ac1ff779a..496f7e755 100644 --- a/core/clients/token_flow.go +++ b/core/clients/token_flow.go @@ -3,15 +3,13 @@ package clients import ( "fmt" "net/http" -) -const ( - // Service Account Token Flow - // Auth flow env variables - ServiceAccountToken = "STACKIT_SERVICE_ACCOUNT_TOKEN" + "github.com/stackitcloud/stackit-sdk-go/core/identity" ) -// TokenFlow handles auth with SA static token +// Deprecated: use identity.EnvServiceAccountToken instead +const ServiceAccountToken = identity.EnvServiceAccountToken + type TokenFlow struct { rt http.RoundTripper config *TokenFlowConfig diff --git a/core/clients/workload_identity_flow.go b/core/clients/workload_identity_flow.go index 73a1ed272..be3173655 100644 --- a/core/clients/workload_identity_flow.go +++ b/core/clients/workload_identity_flow.go @@ -9,27 +9,11 @@ import ( "sync" "time" + "github.com/stackitcloud/stackit-sdk-go/core/identity" "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" "github.com/stackitcloud/stackit-sdk-go/core/utils" ) -const ( - clientIDEnv = "STACKIT_SERVICE_ACCOUNT_EMAIL" - FederatedTokenFileEnv = "STACKIT_FEDERATED_TOKEN_FILE" //nolint:gosec // This is not a secret, just the env variable name - wifTokenEndpointEnv = "STACKIT_IDP_TOKEN_ENDPOINT" //nolint:gosec // This is not a secret, just the env variable name - wifTokenExpirationEnv = "STACKIT_IDP_TOKEN_EXPIRATION_SECONDS" //nolint:gosec // This is not a secret, just the env variable name - - wifClientAssertionType = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" - wifGrantType = "client_credentials" - defaultWifTokenEndpoint = "https://accounts.stackit.cloud/oauth/v2/token" //nolint:gosec // This is not a secret, just the public endpoint for default value - defaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" //nolint:gosec // This is not a secret, just the default path for workload identity token - defaultWifExpirationToken = "1h" -) - -var ( - _ = utils.GetEnvOrDefault(wifTokenExpirationEnv, defaultWifExpirationToken) // Not used yet -) - var _ AuthFlow = &WorkloadIdentityFederationFlow{} // WorkloadIdentityFlow handles auth with Workload Identity Federation @@ -126,15 +110,15 @@ func (c *WorkloadIdentityFederationFlow) Init(cfg *WorkloadIdentityFederationFlo c.config = cfg if c.config.TokenUrl == "" { - c.config.TokenUrl = utils.GetEnvOrDefault(wifTokenEndpointEnv, defaultWifTokenEndpoint) + c.config.TokenUrl = utils.GetEnvOrDefault(identity.EnvIdpTokenEndpoint, identity.WifDefaultTokenEndpoint) } if c.config.ClientID == "" { - c.config.ClientID = utils.GetEnvOrDefault(clientIDEnv, "") + c.config.ClientID = utils.GetEnvOrDefault(identity.EnvServiceAccountEmail, "") } if c.config.FederatedTokenFunction == nil { - c.config.FederatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(FederatedTokenFileEnv, defaultFederatedTokenPath)) + c.config.FederatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(identity.EnvFederatedTokenFile, identity.WifDefaultFederatedTokenPath)) } c.tokenExpirationLeeway = defaultTokenExpirationLeeway @@ -207,8 +191,8 @@ func (c *WorkloadIdentityFederationFlow) createAccessToken() error { func (c *WorkloadIdentityFederationFlow) requestToken(clientID, assertion string) (*http.Response, error) { body := url.Values{} - body.Set("grant_type", wifGrantType) - body.Set("client_assertion_type", wifClientAssertionType) + body.Set("grant_type", identity.WifGrantType) + body.Set("client_assertion_type", identity.WifClientAssertionType) body.Set("client_assertion", assertion) body.Set("client_id", clientID) diff --git a/core/config/config.go b/core/config/config.go index d31cfae75..2c0feca43 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -93,6 +93,8 @@ type Configuration struct { PrivateKeyPath string `json:"privateKeyPath,omitempty"` CredentialsFilePath string `json:"credentialsFilePath,omitempty"` TokenCustomUrl string `json:"tokenCustomUrl,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Resources []string `json:"resources,omitempty"` Region string `json:"region,omitempty"` CustomAuth http.RoundTripper Servers ServerConfigurations @@ -366,6 +368,10 @@ func WithCustomConfiguration(cfg *Configuration) ConfigurationOption { config.Token = cfg.Token config.ServiceAccountKey = cfg.ServiceAccountKey config.ServiceAccountKeyPath = cfg.ServiceAccountKeyPath + config.ServiceAccountEmail = cfg.ServiceAccountEmail + config.ServiceAccountFederatedTokenFunc = cfg.ServiceAccountFederatedTokenFunc + config.Scopes = cfg.Scopes + config.Resources = cfg.Resources config.PrivateKey = cfg.PrivateKey config.PrivateKeyPath = cfg.PrivateKeyPath config.Region = cfg.Region diff --git a/core/identity/auth_constants.go b/core/identity/auth_constants.go new file mode 100644 index 000000000..ce5a69ece --- /dev/null +++ b/core/identity/auth_constants.go @@ -0,0 +1,25 @@ +package identity + +// Workload Identity Federation constants +const ( + // WIF client assertion type for JWT bearer token + WifClientAssertionType = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" + // WIF grant type for token requests + WifGrantType = "client_credentials" + // WIF default token endpoint + WifDefaultTokenEndpoint = "https://accounts.stackit.cloud/oauth/v2/token" + // WIF default federated token file path in Kubernetes + WifDefaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" + // WIF default token expiration leeway + WifDefaultTokenExpiration = "1h" +) + +// Service Account Key Flow constants +const ( + // Service Account Key API endpoint + KeyFlowTokenAPI = "https://accounts.stackit.cloud/oauth/v2/token" + // Default token type for Bearer token + DefaultTokenType = "Bearer" + // Default scope for token requests + DefaultScope = "" +) diff --git a/core/identity/credentials.go b/core/identity/credentials.go new file mode 100644 index 000000000..85f2266a2 --- /dev/null +++ b/core/identity/credentials.go @@ -0,0 +1,97 @@ +package identity + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// CredentialType represents a specific credential field type +type CredentialType string + +// Credentials represents the structure of the credentials file +type Credentials struct { + ServiceAccountEmail string `json:"STACKIT_SERVICE_ACCOUNT_EMAIL"` + ServiceAccountToken string `json:"STACKIT_SERVICE_ACCOUNT_TOKEN"` + ServiceAccountKeyPath string `json:"STACKIT_SERVICE_ACCOUNT_KEY_PATH"` + PrivateKeyPath string `json:"STACKIT_PRIVATE_KEY_PATH"` + ServiceAccountKey string `json:"STACKIT_SERVICE_ACCOUNT_KEY"` + PrivateKey string `json:"STACKIT_PRIVATE_KEY"` +} + +const ( + credentialsFilePath = ".stackit/credentials.json" + CredentialTypeToken CredentialType = "token" + CredentialTypeServiceAccountKey CredentialType = "service_account_key" + CredentialTypeServiceAccountKeyPath CredentialType = "service_account_key_path" + CredentialTypePrivateKey CredentialType = "private_key" + CredentialTypePrivateKeyPath CredentialType = "private_key_path" +) + +var UserHomeDir = os.UserHomeDir + +// ReadCredentialsFile reads the credentials file from the specified path +func ReadCredentialsFile(path string) (*Credentials, error) { + if path == "" { + customPath, customPathSet := os.LookupEnv(EnvCredentialsPath) + if !customPathSet || customPath == "" { + path = credentialsFilePath + home, err := UserHomeDir() + if err != nil { + return nil, fmt.Errorf("getting home directory: %w", err) + } + path = filepath.Join(home, path) + } else { + path = customPath + } + } + + credentialsRaw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("opening file: %w", err) + } + + var credentials Credentials + err = json.Unmarshal(credentialsRaw, &credentials) + if err != nil { + return nil, fmt.Errorf("unmarshalling credentials: %w", err) + } + return &credentials, nil +} + +// ReadCredential reads the specified credential type from Credentials +func ReadCredential(cred CredentialType, credentials *Credentials) (string, error) { + var credentialValue string + switch cred { + case CredentialTypeToken: + credentialValue = credentials.ServiceAccountToken + if credentialValue == "" { + return credentialValue, fmt.Errorf("token is empty or not set") + } + case CredentialTypeServiceAccountKeyPath: + credentialValue = credentials.ServiceAccountKeyPath + if credentialValue == "" { + return credentialValue, fmt.Errorf("service account key path is empty or not set") + } + case CredentialTypePrivateKeyPath: + credentialValue = credentials.PrivateKeyPath + if credentialValue == "" { + return credentialValue, fmt.Errorf("private key path is empty or not set") + } + case CredentialTypeServiceAccountKey: + credentialValue = credentials.ServiceAccountKey + if credentialValue == "" { + return credentialValue, fmt.Errorf("service account key is empty or not set") + } + case CredentialTypePrivateKey: + credentialValue = credentials.PrivateKey + if credentialValue == "" { + return credentialValue, fmt.Errorf("private key is empty or not set") + } + default: + return "", fmt.Errorf("invalid credential type: %s", cred) + } + + return credentialValue, nil +} diff --git a/core/identity/env_vars.go b/core/identity/env_vars.go new file mode 100644 index 000000000..325b2098d --- /dev/null +++ b/core/identity/env_vars.go @@ -0,0 +1,21 @@ +package identity + +// Environment variable names for STACKIT authentication +const ( + // Service Account authentication + EnvServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" + EnvServiceAccountToken = "STACKIT_SERVICE_ACCOUNT_TOKEN" + EnvServiceAccountKey = "STACKIT_SERVICE_ACCOUNT_KEY" + EnvServiceAccountKeyPath = "STACKIT_SERVICE_ACCOUNT_KEY_PATH" + EnvPrivateKey = "STACKIT_PRIVATE_KEY" + EnvPrivateKeyPath = "STACKIT_PRIVATE_KEY_PATH" + EnvCredentialsPath = "STACKIT_CREDENTIALS_PATH" + + // Workload Identity Federation + EnvFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE" + EnvIdpTokenEndpoint = "STACKIT_IDP_TOKEN_ENDPOINT" + EnvIdpTokenExpirationSec = "STACKIT_IDP_TOKEN_EXPIRATION_SECONDS" + + // Token endpoint + EnvTokenBaseUrl = "STACKIT_TOKEN_BASEURL" +) diff --git a/core/identity/http_client.go b/core/identity/http_client.go new file mode 100644 index 000000000..3777d4f52 --- /dev/null +++ b/core/identity/http_client.go @@ -0,0 +1,29 @@ +package identity + +import ( + "net/http" + "time" +) + +// authHTTPClient creates a new http.Client with only the transport from the given client. +// This avoids passing a client that might already have auth roundtrippers attached, +// which would cause deadlocks during token acquisition. +// If timeout > 0, it will be applied to the returned client. +func authHTTPClient(httpClient *http.Client, timeout time.Duration) *http.Client { + var transport http.RoundTripper + + // Extract transport from the provided client if available + if httpClient != nil && httpClient.Transport != nil { + transport = httpClient.Transport + } + + // Create a new clean client with just the transport + client := &http.Client{Transport: transport} + + // Apply timeout if specified + if timeout > 0 { + client.Timeout = timeout + } + + return client +} diff --git a/core/identity/instance_metadata_provider.go b/core/identity/instance_metadata_provider.go index b9034b4a2..79abe5a9d 100644 --- a/core/identity/instance_metadata_provider.go +++ b/core/identity/instance_metadata_provider.go @@ -52,10 +52,8 @@ func NewInstanceMetadataProvider(cfg InstanceMetadataProviderConfig) (*InstanceM return nil, fmt.Errorf("%s: service account email cannot be empty", instanceMetadataErrorPrefix) } - httpClient := cfg.HTTPClient - if httpClient == nil { - httpClient = &http.Client{Timeout: time.Minute} - } + // Create a clean client to avoid deadlocks if the provided client already has auth roundtrippers + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) leeway := cfg.TokenRefreshLeeway if leeway == 0 { diff --git a/core/identity/service_account_key_provider.go b/core/identity/service_account_key_provider.go index 9f54032e3..727eb10e8 100644 --- a/core/identity/service_account_key_provider.go +++ b/core/identity/service_account_key_provider.go @@ -28,11 +28,11 @@ const ( type ServiceAccountKeyProviderConfig struct { // ServiceAccountKey is the service account key as a JSON string. // If empty, attempts to read from STACKIT_SERVICE_ACCOUNT_KEY environment variable, - // then from STACKIT_SERVICE_ACCOUNT_KEY_PATH, then from credentials file. + // then from STACKIT_SERVICE_ACCOUNT_KEY_PATH, then optionally from credentials file if CredentialsFilePath is set. ServiceAccountKey string // PrivateKey is the RSA private key as a PEM-encoded string. // If empty, attempts to read from STACKIT_PRIVATE_KEY environment variable, - // then from STACKIT_PRIVATE_KEY_PATH, then from credentials file. + // then from STACKIT_PRIVATE_KEY_PATH, then optionally from credentials file if CredentialsFilePath is set. PrivateKey string // TokenURL overrides the token endpoint. If empty, the value from the service account key is used, // falling back to the default STACKIT token endpoint. @@ -42,6 +42,15 @@ type ServiceAccountKeyProviderConfig struct { TokenRefreshLeeway time.Duration // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. HTTPClient *http.Client + // CredentialsFilePath is a pointer to the path to the credentials file. When set (not nil), it enables + // automatic resolution of key/private key from the credentials file if they are not found in config or + // environment variables. If the pointer is nil, credentials file resolution is skipped. + // When set to a pointer to an empty string, uses the default credentials file path (~/.stackit/credentials.json). + CredentialsFilePath *string + // Optional scopes to request for the access token + Scopes []string + // Optional resources to request for the access token + Resources []string } var _ TokenProvider = (*ServiceAccountKeyProvider)(nil) @@ -57,6 +66,8 @@ type ServiceAccountKeyProvider struct { tokenLeeway time.Duration tokenMutex sync.RWMutex token Token + scopes string + resources []string } type oauthTokenResponse struct { @@ -71,15 +82,23 @@ func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*Service serviceAccountKeyJSON := cfg.ServiceAccountKey if serviceAccountKeyJSON == "" { // Try STACKIT_SERVICE_ACCOUNT_KEY env var - if key, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_KEY"); exists && key != "" { + if key, exists := os.LookupEnv(EnvServiceAccountKey); exists && key != "" { serviceAccountKeyJSON = key - } else if keyPath, exists := os.LookupEnv("STACKIT_SERVICE_ACCOUNT_KEY_PATH"); exists && keyPath != "" { + } else if keyPath, exists := os.LookupEnv(EnvServiceAccountKeyPath); exists && keyPath != "" { // Try STACKIT_SERVICE_ACCOUNT_KEY_PATH env var data, err := os.ReadFile(keyPath) if err != nil { return nil, fmt.Errorf("%s: read service account key from path: %w", serviceAccountKeyErrorPrefix, err) } serviceAccountKeyJSON = string(data) + } else if cfg.CredentialsFilePath != nil { + // Try credentials file if pointer is set (not nil) + credentials, err := ReadCredentialsFile(*cfg.CredentialsFilePath) + if err == nil { + if key, err := ReadCredential(CredentialTypeServiceAccountKey, credentials); err == nil { + serviceAccountKeyJSON = key + } + } } } @@ -97,13 +116,13 @@ func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*Service return nil, fmt.Errorf("%s: service account key credentials cannot be empty", serviceAccountKeyErrorPrefix) } - // Resolve private key from config, env vars, credentials file, or embedded in service account key + // Resolve private key from config, env vars, embedded in service account key, or credentials file privateKeyPEM := cfg.PrivateKey if privateKeyPEM == "" { // Try STACKIT_PRIVATE_KEY env var - if key, exists := os.LookupEnv("STACKIT_PRIVATE_KEY"); exists && key != "" { + if key, exists := os.LookupEnv(EnvPrivateKey); exists && key != "" { privateKeyPEM = key - } else if keyPath, exists := os.LookupEnv("STACKIT_PRIVATE_KEY_PATH"); exists && keyPath != "" { + } else if keyPath, exists := os.LookupEnv(EnvPrivateKeyPath); exists && keyPath != "" { // Try STACKIT_PRIVATE_KEY_PATH env var data, err := os.ReadFile(keyPath) if err != nil { @@ -111,8 +130,16 @@ func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*Service } privateKeyPEM = string(data) } else if serviceAccountKey.Credentials.PrivateKey != nil { - // Fall back to private key embedded in service account key + // Try private key embedded in service account key privateKeyPEM = *serviceAccountKey.Credentials.PrivateKey + } else if cfg.CredentialsFilePath != nil { + // Try credentials file if pointer is set and not found elsewhere + credentials, err := ReadCredentialsFile(*cfg.CredentialsFilePath) + if err == nil { + if key, err := ReadCredential(CredentialTypePrivateKey, credentials); err == nil { + privateKeyPEM = key + } + } } } @@ -135,11 +162,8 @@ func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*Service tokenURL = serviceAccountKeyDefaultTokenURL } - // Resolve HTTP client - httpClient := cfg.HTTPClient - if httpClient == nil { - httpClient = &http.Client{Timeout: time.Minute} - } + // Resolve HTTP client - use clean client to avoid deadlocks if the provided client already has auth roundtrippers + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) // Resolve token refresh leeway leeway := cfg.TokenRefreshLeeway @@ -154,6 +178,8 @@ func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*Service json: serviceAccountKey, privateKey: privateKey, tokenLeeway: leeway, + scopes: strings.Join(cfg.Scopes, " "), + resources: cfg.Resources, }, nil } @@ -194,6 +220,12 @@ func (p *ServiceAccountKeyProvider) requestToken(ctx context.Context) (Token, er body := url.Values{} body.Set("grant_type", serviceAccountJWTBearerGrantType) body.Set("assertion", assertion) + if p.scopes != "" { + body.Set("scope", p.scopes) + } + for _, resource := range p.resources { + body.Add("resource", resource) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) if err != nil { @@ -209,6 +241,7 @@ func (p *ServiceAccountKeyProvider) requestToken(ctx context.Context) (Token, er if res.StatusCode != http.StatusOK { bodyRaw, _ := io.ReadAll(res.Body) + ErrorContext(ctx, "identity: token request failed", "status", res.StatusCode, "body", string(bodyRaw)) return Token{}, fmt.Errorf("%s: token request failed with status %d: %s", serviceAccountKeyErrorPrefix, res.StatusCode, string(bodyRaw)) } diff --git a/core/identity/service_account_key_provider_test.go b/core/identity/service_account_key_provider_test.go index 84b4f6778..00ab09b59 100644 --- a/core/identity/service_account_key_provider_test.go +++ b/core/identity/service_account_key_provider_test.go @@ -98,6 +98,281 @@ func TestServiceAccountKeyRequiresKeyAndPrivateKey(t *testing.T) { } } +func TestServiceAccountKeyWithScopes(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := ServiceAccountJson{ + Credentials: &ServiceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{"scope1", "scope2"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expected := "scope1 scope2" + if scopesReceived != expected { + t.Fatalf("expected scopes %q, got %q", expected, scopesReceived) + } +} + +func TestServiceAccountKeyWithResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := ServiceAccountJson{ + Credentials: &ServiceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Resources: []string{"resource1", "resource2", "resource3"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expected := []string{"resource1", "resource2", "resource3"} + if len(resourcesReceived) != len(expected) { + t.Fatalf("expected %d resources, got %d", len(expected), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != expected[i] { + t.Fatalf("expected resource %q at index %d, got %q", expected[i], i, res) + } + } +} + +func TestServiceAccountKeyWithScopesAndResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := ServiceAccountJson{ + Credentials: &ServiceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{"read", "write"}, + Resources: []string{"resourceA", "resourceB"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expectedScopes := "read write" + if scopesReceived != expectedScopes { + t.Fatalf("expected scopes %q, got %q", expectedScopes, scopesReceived) + } + + expectedResources := []string{"resourceA", "resourceB"} + if len(resourcesReceived) != len(expectedResources) { + t.Fatalf("expected %d resources, got %d", len(expectedResources), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != expectedResources[i] { + t.Fatalf("expected resource %q at index %d, got %q", expectedResources[i], i, res) + } + } +} + +func TestServiceAccountKeyWithEmptyScopesAndResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := ServiceAccountJson{ + Credentials: &ServiceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{}, + Resources: []string{}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + if scopesReceived != "" { + t.Fatalf("expected empty scopes, got %q", scopesReceived) + } + if len(resourcesReceived) != 0 { + t.Fatalf("expected no resources, got %d", len(resourcesReceived)) + } +} + func generateRSAPrivateKeyPEM() ([]byte, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/core/identity/static_token_provider.go b/core/identity/static_token_provider.go index 5cdb04ed2..477e0f317 100644 --- a/core/identity/static_token_provider.go +++ b/core/identity/static_token_provider.go @@ -10,8 +10,14 @@ var _ TokenProvider = (*StaticTokenProvider)(nil) // StaticTokenProviderConfig contains configuration for StaticTokenProvider. type StaticTokenProviderConfig struct { - // Token is the static access token. If empty, will attempt to resolve from STACKIT_SERVICE_ACCOUNT_TOKEN env var. + // Token is the static access token. If empty, will attempt to resolve from STACKIT_SERVICE_ACCOUNT_TOKEN env var + // or from credentials file if CredentialsFilePath is set. Token string + // CredentialsFilePath is a pointer to the path to the credentials file. When set (not nil), it enables + // automatic resolution of token from the credentials file if it is not found in config or environment variables. + // If the pointer is nil, credentials file resolution is skipped. + // When set to a pointer to an empty string, uses the default credentials file path (~/.stackit/credentials.json). + CredentialsFilePath *string } // StaticTokenProvider provides a static access token. @@ -24,10 +30,20 @@ type StaticTokenProvider struct { func NewStaticTokenProvider(cfg StaticTokenProviderConfig) (*StaticTokenProvider, error) { token := cfg.Token if token == "" { - token, _ = os.LookupEnv("STACKIT_SERVICE_ACCOUNT_TOKEN") + if val, found := os.LookupEnv(EnvServiceAccountToken); found && val != "" { + token = val + } else if cfg.CredentialsFilePath != nil { + // Try credentials file if pointer is set (not nil) + credentials, err := ReadCredentialsFile(*cfg.CredentialsFilePath) + if err == nil { + if credToken, err := ReadCredential(CredentialTypeToken, credentials); err == nil { + token = credToken + } + } + } } if token == "" { - return nil, fmt.Errorf("static token provider: STACKIT_SERVICE_ACCOUNT_TOKEN not set and no token provided in config") + return nil, fmt.Errorf("static token provider: %s not set and no token provided in config", EnvServiceAccountToken) } expiresOn, err := getTokenExpiration(token, 0) @@ -50,7 +66,7 @@ func NewStaticTokenProvider(cfg StaticTokenProviderConfig) (*StaticTokenProvider // Token returns the static access token, parsing its expiration from the JWT claims. func (p *StaticTokenProvider) Token(_ context.Context, _ TokenRequestOptions) (Token, error) { if p.token.AccessToken == "" { - return Token{}, fmt.Errorf("static token is empty") + return Token{}, fmt.Errorf("static token provider: token is empty") } return p.token, nil } diff --git a/core/identity/workload_identity_federation_provider.go b/core/identity/workload_identity_federation_provider.go index 08b8bfdf1..cf0192e53 100644 --- a/core/identity/workload_identity_federation_provider.go +++ b/core/identity/workload_identity_federation_provider.go @@ -16,15 +16,9 @@ import ( ) const ( - workloadIdentityClientIDEnv = "STACKIT_SERVICE_ACCOUNT_EMAIL" - workloadIdentityFederatedTokenEnv = "STACKIT_FEDERATED_TOKEN_FILE" - workloadIdentityTokenEndpointEnv = "STACKIT_IDP_TOKEN_ENDPOINT" - workloadIdentityDefaultTokenURL = "https://accounts.stackit.cloud/oauth/v2/token" - workloadIdentityDefaultTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" - workloadIdentityClientAssertionType = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" - workloadIdentityGrantType = "client_credentials" - workloadIdentityDefaultLeeway = time.Minute - workloadIdentityErrorPrefix = "workload identity federation provider" + workloadIdentityClientIDEnv = EnvServiceAccountEmail + workloadIdentityDefaultLeeway = time.Minute + workloadIdentityErrorPrefix = "workload identity federation provider" ) // WorkloadIdentityFederationProviderConfig contains the configuration for WorkloadIdentityFederationProvider. @@ -41,6 +35,10 @@ type WorkloadIdentityFederationProviderConfig struct { TokenRefreshLeeway time.Duration // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. HTTPClient *http.Client + // Optional scopes to request for the access token + Scopes []string + // Optional resources to request for the access token + Resources []string } var _ TokenProvider = (*WorkloadIdentityFederationProvider)(nil) @@ -55,6 +53,8 @@ type WorkloadIdentityFederationProvider struct { tokenLeeway time.Duration tokenMutex sync.RWMutex token Token + scopes string + resources []string } type workloadIdentityTokenResponse struct { @@ -66,7 +66,7 @@ type workloadIdentityTokenResponse struct { func NewWorkloadIdentityFederationProvider(cfg WorkloadIdentityFederationProviderConfig) (*WorkloadIdentityFederationProvider, error) { tokenURL := cfg.TokenURL if tokenURL == "" { - tokenURL = utils.GetEnvOrDefault(workloadIdentityTokenEndpointEnv, workloadIdentityDefaultTokenURL) + tokenURL = utils.GetEnvOrDefault(EnvIdpTokenEndpoint, WifDefaultTokenEndpoint) } clientID := cfg.ClientID @@ -79,16 +79,13 @@ func NewWorkloadIdentityFederationProvider(cfg WorkloadIdentityFederationProvide federatedTokenFunction := cfg.FederatedTokenFunction if federatedTokenFunction == nil { - federatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(workloadIdentityFederatedTokenEnv, workloadIdentityDefaultTokenPath)) + federatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(EnvFederatedTokenFile, WifDefaultFederatedTokenPath)) } if _, err := federatedTokenFunction(context.Background()); err != nil { return nil, fmt.Errorf("%s: error reading federated token file: %w", workloadIdentityErrorPrefix, err) } - httpClient := cfg.HTTPClient - if httpClient == nil { - httpClient = &http.Client{Timeout: time.Minute} - } + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) leeway := cfg.TokenRefreshLeeway if leeway == 0 { @@ -102,6 +99,8 @@ func NewWorkloadIdentityFederationProvider(cfg WorkloadIdentityFederationProvide clientID: clientID, federatedTokenFunction: federatedTokenFunction, tokenLeeway: leeway, + scopes: strings.Join(cfg.Scopes, " "), + resources: cfg.Resources, }, nil } @@ -140,10 +139,16 @@ func (p *WorkloadIdentityFederationProvider) requestToken(ctx context.Context) ( } body := url.Values{} - body.Set("grant_type", workloadIdentityGrantType) - body.Set("client_assertion_type", workloadIdentityClientAssertionType) + body.Set("grant_type", WifGrantType) + body.Set("client_assertion_type", WifClientAssertionType) body.Set("client_assertion", clientAssertion) body.Set("client_id", p.clientID) + if p.scopes != "" { + body.Set("scope", p.scopes) + } + for _, resource := range p.resources { + body.Add("resource", resource) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) if err != nil { diff --git a/core/identity/workload_identity_federation_provider_test.go b/core/identity/workload_identity_federation_provider_test.go index 1d4d7b097..81975c5cf 100644 --- a/core/identity/workload_identity_federation_provider_test.go +++ b/core/identity/workload_identity_federation_provider_test.go @@ -34,10 +34,10 @@ func TestWorkloadIdentityFederationToken(t *testing.T) { if err := r.ParseForm(); err != nil { t.Fatalf("parse form: %v", err) } - if r.PostForm.Get("grant_type") != workloadIdentityGrantType { + if r.PostForm.Get("grant_type") != WifGrantType { t.Fatalf("unexpected grant type: %s", r.PostForm.Get("grant_type")) } - if r.PostForm.Get("client_assertion_type") != workloadIdentityClientAssertionType { + if r.PostForm.Get("client_assertion_type") != WifClientAssertionType { t.Fatalf("unexpected assertion type: %s", r.PostForm.Get("client_assertion_type")) } if r.PostForm.Get("client_assertion") == "" { From 14f865b6370e90c96b7ef3df386319ca64add630 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Thu, 9 Jul 2026 23:58:55 +0200 Subject: [PATCH 3/4] lint Signed-off-by: Jorge Turrado --- core/auth/auth.go | 14 +++++++------- core/auth/token_provider_roundtripper_test.go | 18 ++++++++++++------ core/clients/key_flow.go | 2 +- core/clients/key_flow_test.go | 3 ++- core/identity/auth_constants.go | 3 +++ core/identity/chained_provider_test.go | 18 +++++++++--------- core/identity/credentials.go | 1 + core/identity/env_vars.go | 11 ++++++++--- core/identity/instance_metadata_provider.go | 12 +++++++++--- .../instance_metadata_provider_test.go | 12 ++++++------ core/identity/service_account_key_provider.go | 11 +++++++++-- .../service_account_key_provider_test.go | 12 ++++++------ core/identity/static_token_provider.go | 7 ++++++- core/identity/static_token_provider_test.go | 5 +++-- .../workload_identity_federation_provider.go | 7 +++++-- ...rkload_identity_federation_provider_test.go | 4 ++-- examples/authentication/go.mod | 2 ++ examples/authentication/go.sum | 2 -- 18 files changed, 91 insertions(+), 53 deletions(-) diff --git a/core/auth/auth.go b/core/auth/auth.go index 3d97cd2f4..26c16edc8 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -74,7 +74,7 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { // Try Instance Metadata Service (IMS) with aggressive timeout to fail fast if not in cloud email := getServiceAccountEmail(cfg) if email != "" { - if imsProvider, err := identity.NewInstanceMetadataProvider(identity.InstanceMetadataProviderConfig{ + if imsProvider, err := identity.NewInstanceMetadataProvider(&identity.InstanceMetadataProviderConfig{ ServiceAccountEmail: email, HTTPClient: cfg.HTTPClient, }); err == nil { @@ -83,7 +83,7 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { } // Try Workload Identity Federation - provider handles client cleanup internally - if wifProvider, err := identity.NewWorkloadIdentityFederationProvider(identity.WorkloadIdentityFederationProviderConfig{ + if wifProvider, err := identity.NewWorkloadIdentityFederationProvider(&identity.WorkloadIdentityFederationProviderConfig{ TokenURL: cfg.TokenCustomUrl, ClientID: cfg.ServiceAccountEmail, FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, @@ -95,7 +95,7 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { } // Try Service Account Key - provider handles client cleanup internally and credentials file resolution - if keyProvider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ + if keyProvider, err := identity.NewServiceAccountKeyProvider(&identity.ServiceAccountKeyProviderConfig{ ServiceAccountKey: cfg.ServiceAccountKey, PrivateKey: cfg.PrivateKey, TokenURL: cfg.TokenCustomUrl, @@ -108,7 +108,7 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { } // Try Static Token - provider handles env var resolution and credentials file resolution - if staticProvider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ + if staticProvider, err := identity.NewStaticTokenProvider(&identity.StaticTokenProviderConfig{ Token: cfg.Token, CredentialsFilePath: &cfg.CredentialsFilePath, }); err == nil { @@ -162,7 +162,7 @@ func TokenAuth(cfg *config.Configuration) (http.RoundTripper, error) { return nil, fmt.Errorf("STACKIT_SERVICE_ACCOUNT_TOKEN not set and no token found in credentials file") } - provider, err := identity.NewStaticTokenProvider(identity.StaticTokenProviderConfig{ + provider, err := identity.NewStaticTokenProvider(&identity.StaticTokenProviderConfig{ Token: token, CredentialsFilePath: &cfg.CredentialsFilePath, }) @@ -191,7 +191,7 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) { return nil, fmt.Errorf("configuring key authentication: %w", err) } - provider, err := identity.NewServiceAccountKeyProvider(identity.ServiceAccountKeyProviderConfig{ + provider, err := identity.NewServiceAccountKeyProvider(&identity.ServiceAccountKeyProviderConfig{ ServiceAccountKey: cfg.ServiceAccountKey, PrivateKey: cfg.PrivateKey, TokenURL: cfg.TokenCustomUrl, @@ -208,7 +208,7 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) { // WorkloadIdentityFederationAuth configures the wif flow and returns an http.RoundTripper // that can be used to make authenticated requests using an access token func WorkloadIdentityFederationAuth(cfg *config.Configuration) (http.RoundTripper, error) { - provider, err := identity.NewWorkloadIdentityFederationProvider(identity.WorkloadIdentityFederationProviderConfig{ + provider, err := identity.NewWorkloadIdentityFederationProvider(&identity.WorkloadIdentityFederationProviderConfig{ TokenURL: cfg.TokenCustomUrl, ClientID: cfg.ServiceAccountEmail, FederatedTokenFunction: cfg.ServiceAccountFederatedTokenFunc, diff --git a/core/auth/token_provider_roundtripper_test.go b/core/auth/token_provider_roundtripper_test.go index d341a8132..38f8dd9ab 100644 --- a/core/auth/token_provider_roundtripper_test.go +++ b/core/auth/token_provider_roundtripper_test.go @@ -17,7 +17,7 @@ type tokenProviderStub struct { err error } -func (s tokenProviderStub) Token(context.Context, identity.TokenRequestOptions) (identity.Token, error) { +func (s *tokenProviderStub) Token(context.Context, identity.TokenRequestOptions) (identity.Token, error) { if s.err != nil { return identity.Token{}, s.err } @@ -31,7 +31,7 @@ func (f roundTripperStub) RoundTrip(req *http.Request) (*http.Response, error) { } func TestTokenProviderRoundTripper(t *testing.T) { - rt := newTokenProviderRoundTripper(tokenProviderStub{ + rt := newTokenProviderRoundTripper(&tokenProviderStub{ token: identity.Token{AccessToken: "token-value", RefreshOn: time.Now().Add(time.Hour)}, }, roundTripperStub(func(req *http.Request) (*http.Response, error) { if req.Header.Get("Authorization") != "Bearer token-value" { @@ -40,7 +40,7 @@ func TestTokenProviderRoundTripper(t *testing.T) { return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header)}, nil })) - req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) if err != nil { t.Fatalf("create request: %v", err) } @@ -52,22 +52,28 @@ func TestTokenProviderRoundTripper(t *testing.T) { if res.StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", res.StatusCode) } + if err := res.Body.Close(); err != nil { + t.Fatalf("close response body: %v", err) + } } func TestTokenProviderRoundTripperProviderError(t *testing.T) { - rt := newTokenProviderRoundTripper(tokenProviderStub{err: fmt.Errorf("failed")}, roundTripperStub(func(req *http.Request) (*http.Response, error) { + rt := newTokenProviderRoundTripper(&tokenProviderStub{err: fmt.Errorf("failed")}, roundTripperStub(func(_ *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unexpected") })) - req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) if err != nil { t.Fatalf("create request: %v", err) } - _, err = rt.RoundTrip(req) + res, err := rt.RoundTrip(req) //nolint:bodyclose // Not executed due to error in provider if err == nil { t.Fatalf("expected error") } + if res != nil { + res.Body.Close() // nolint:errcheck // Response may be nil, only closing if not + } if !strings.Contains(err.Error(), "get access token from provider") { t.Fatalf("unexpected error: %v", err) } diff --git a/core/clients/key_flow.go b/core/clients/key_flow.go index fdc779934..7dc03b9ef 100644 --- a/core/clients/key_flow.go +++ b/core/clients/key_flow.go @@ -14,11 +14,11 @@ import ( "sync" "time" + "github.com/stackitcloud/stackit-sdk-go/core/identity" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/core/identity" ) var _ AuthFlow = &KeyFlow{} diff --git a/core/clients/key_flow_test.go b/core/clients/key_flow_test.go index 00425d155..d3ad9f89d 100644 --- a/core/clients/key_flow_test.go +++ b/core/clients/key_flow_test.go @@ -17,10 +17,11 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-sdk-go/core/identity" + "github.com/golang-jwt/jwt/v5" "github.com/google/go-cmp/cmp" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/core/identity" ) var ( diff --git a/core/identity/auth_constants.go b/core/identity/auth_constants.go index ce5a69ece..cd8439310 100644 --- a/core/identity/auth_constants.go +++ b/core/identity/auth_constants.go @@ -7,8 +7,10 @@ const ( // WIF grant type for token requests WifGrantType = "client_credentials" // WIF default token endpoint + // nolint:gosec // G101 False positive: This is a constant URL, not a credential WifDefaultTokenEndpoint = "https://accounts.stackit.cloud/oauth/v2/token" // WIF default federated token file path in Kubernetes + // nolint:gosec // G101 False positive: This is a constant path, not a credential WifDefaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" // WIF default token expiration leeway WifDefaultTokenExpiration = "1h" @@ -17,6 +19,7 @@ const ( // Service Account Key Flow constants const ( // Service Account Key API endpoint + // nolint:gosec // G101 False positive: This is a constant URL, not a credential KeyFlowTokenAPI = "https://accounts.stackit.cloud/oauth/v2/token" // Default token type for Bearer token DefaultTokenType = "Bearer" diff --git a/core/identity/chained_provider_test.go b/core/identity/chained_provider_test.go index 17299067e..783001c4e 100644 --- a/core/identity/chained_provider_test.go +++ b/core/identity/chained_provider_test.go @@ -14,7 +14,7 @@ type staticProvider struct { err error } -func (s staticProvider) Token(context.Context, TokenRequestOptions) (Token, error) { +func (s *staticProvider) Token(context.Context, TokenRequestOptions) (Token, error) { if s.err != nil { return Token{}, s.err } @@ -24,8 +24,8 @@ func (s staticProvider) Token(context.Context, TokenRequestOptions) (Token, erro func TestChainToken(t *testing.T) { expected := Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)} chain, err := NewChainedProvider( - staticProvider{err: fmt.Errorf("first failed")}, - staticProvider{token: expected}, + &staticProvider{err: fmt.Errorf("first failed")}, + &staticProvider{token: expected}, ) if err != nil { t.Fatalf("new chain: %v", err) @@ -42,8 +42,8 @@ func TestChainToken(t *testing.T) { func TestChainTokenAllFailures(t *testing.T) { chain, err := NewChainedProvider( - staticProvider{err: fmt.Errorf("first failed")}, - staticProvider{err: fmt.Errorf("second failed")}, + &staticProvider{err: fmt.Errorf("first failed")}, + &staticProvider{err: fmt.Errorf("second failed")}, ) if err != nil { t.Fatalf("new chain: %v", err) @@ -72,8 +72,8 @@ func TestChainCachesSuccessfulProviderByDefault(t *testing.T) { var firstCalls int32 var secondCalls int32 - first := staticProvider{err: fmt.Errorf("first failed")} - second := staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + first := &staticProvider{err: fmt.Errorf("first failed")} + second := &staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} countingFirst := countingProvider{inner: first, count: &firstCalls} countingSecond := countingProvider{inner: second, count: &secondCalls} @@ -104,8 +104,8 @@ func TestChainRetrySourcesOption(t *testing.T) { var firstCalls int32 var secondCalls int32 - first := staticProvider{err: fmt.Errorf("first failed")} - second := staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + first := &staticProvider{err: fmt.Errorf("first failed")} + second := &staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} countingFirst := countingProvider{inner: first, count: &firstCalls} countingSecond := countingProvider{inner: second, count: &secondCalls} diff --git a/core/identity/credentials.go b/core/identity/credentials.go index 85f2266a2..0bf8b58bf 100644 --- a/core/identity/credentials.go +++ b/core/identity/credentials.go @@ -21,6 +21,7 @@ type Credentials struct { } const ( + // nolint:gosec // G101 False positive: This is a constant path, not a credential credentialsFilePath = ".stackit/credentials.json" CredentialTypeToken CredentialType = "token" CredentialTypeServiceAccountKey CredentialType = "service_account_key" diff --git a/core/identity/env_vars.go b/core/identity/env_vars.go index 325b2098d..0de4237fa 100644 --- a/core/identity/env_vars.go +++ b/core/identity/env_vars.go @@ -9,13 +9,18 @@ const ( EnvServiceAccountKeyPath = "STACKIT_SERVICE_ACCOUNT_KEY_PATH" EnvPrivateKey = "STACKIT_PRIVATE_KEY" EnvPrivateKeyPath = "STACKIT_PRIVATE_KEY_PATH" - EnvCredentialsPath = "STACKIT_CREDENTIALS_PATH" + // nolint:gosec // G101 False positive: This is a constant path, not a credential + EnvCredentialsPath = "STACKIT_CREDENTIALS_PATH" // Workload Identity Federation - EnvFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE" - EnvIdpTokenEndpoint = "STACKIT_IDP_TOKEN_ENDPOINT" + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential + EnvFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE" + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential + EnvIdpTokenEndpoint = "STACKIT_IDP_TOKEN_ENDPOINT" + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential EnvIdpTokenExpirationSec = "STACKIT_IDP_TOKEN_EXPIRATION_SECONDS" // Token endpoint + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential EnvTokenBaseUrl = "STACKIT_TOKEN_BASEURL" ) diff --git a/core/identity/instance_metadata_provider.go b/core/identity/instance_metadata_provider.go index 79abe5a9d..ac5d15c37 100644 --- a/core/identity/instance_metadata_provider.go +++ b/core/identity/instance_metadata_provider.go @@ -47,7 +47,11 @@ type instanceMetadataTokenResponse struct { } // NewInstanceMetadataProvider creates an InstanceMetadata provider. -func NewInstanceMetadataProvider(cfg InstanceMetadataProviderConfig) (*InstanceMetadataProvider, error) { +func NewInstanceMetadataProvider(cfg *InstanceMetadataProviderConfig) (*InstanceMetadataProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", instanceMetadataErrorPrefix) + } + if cfg.ServiceAccountEmail == "" { return nil, fmt.Errorf("%s: service account email cannot be empty", instanceMetadataErrorPrefix) } @@ -101,7 +105,7 @@ func (p *InstanceMetadataProvider) Token(ctx context.Context, _ TokenRequestOpti func (p *InstanceMetadataProvider) requestToken(ctx context.Context) (Token, error) { url := strings.ReplaceAll(p.endpointTemplate, instanceMetadataPathPlaceholder, p.serviceAccount) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return Token{}, fmt.Errorf("%s: create instance metadata request: %w", instanceMetadataErrorPrefix, err) } @@ -110,7 +114,9 @@ func (p *InstanceMetadataProvider) requestToken(ctx context.Context) (Token, err if err != nil { return Token{}, fmt.Errorf("%s: request instance metadata token: %w", instanceMetadataErrorPrefix, err) } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() if res.StatusCode != http.StatusOK { return Token{}, fmt.Errorf("%s: instance metadata request failed with status %d", instanceMetadataErrorPrefix, res.StatusCode) diff --git a/core/identity/instance_metadata_provider_test.go b/core/identity/instance_metadata_provider_test.go index 450f4851e..c50972a3f 100644 --- a/core/identity/instance_metadata_provider_test.go +++ b/core/identity/instance_metadata_provider_test.go @@ -34,7 +34,7 @@ func TestInstanceMetadataTokenCachesResponse(t *testing.T) { })) defer server.Close() - provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ ServiceAccountEmail: "test@sa.stackit.cloud", HTTPClient: server.Client(), }) @@ -60,14 +60,14 @@ func TestInstanceMetadataTokenCachesResponse(t *testing.T) { } func TestInstanceMetadataTokenRequiresServiceAccountEmail(t *testing.T) { - _, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{}) + _, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{}) if err == nil { t.Fatalf("expected error") } } func TestInstanceMetadataTokenInvalidValidUntil(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "token": "token", "validUntil": "invalid", @@ -75,7 +75,7 @@ func TestInstanceMetadataTokenInvalidValidUntil(t *testing.T) { })) defer server.Close() - provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ ServiceAccountEmail: "test@sa.stackit.cloud", HTTPClient: server.Client(), }) @@ -91,12 +91,12 @@ func TestInstanceMetadataTokenInvalidValidUntil(t *testing.T) { } func TestInstanceMetadataTokenHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() - provider, err := NewInstanceMetadataProvider(InstanceMetadataProviderConfig{ + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ ServiceAccountEmail: "test@sa.stackit.cloud", HTTPClient: server.Client(), }) diff --git a/core/identity/service_account_key_provider.go b/core/identity/service_account_key_provider.go index 727eb10e8..0dd61b7a2 100644 --- a/core/identity/service_account_key_provider.go +++ b/core/identity/service_account_key_provider.go @@ -18,7 +18,9 @@ import ( ) const ( + // nolint:gosec // G101 False positive: This is a constant URL, not a credential serviceAccountKeyDefaultTokenURL = "https://accounts.stackit.cloud/oauth/v2/token" + // nolint:gosec // G101 False positive: This is a constant protocol string, not a credential serviceAccountJWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" serviceAccountKeyDefaultLeeway = time.Minute serviceAccountKeyErrorPrefix = "service account key provider" @@ -77,7 +79,10 @@ type oauthTokenResponse struct { // NewServiceAccountKeyProvider creates a ServiceAccountKey provider. // It resolves configuration from environment variables if not provided in the config. -func NewServiceAccountKeyProvider(cfg ServiceAccountKeyProviderConfig) (*ServiceAccountKeyProvider, error) { +func NewServiceAccountKeyProvider(cfg *ServiceAccountKeyProviderConfig) (*ServiceAccountKeyProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", serviceAccountKeyErrorPrefix) + } // Resolve service account key from config, env vars, or credentials file serviceAccountKeyJSON := cfg.ServiceAccountKey if serviceAccountKeyJSON == "" { @@ -237,7 +242,9 @@ func (p *ServiceAccountKeyProvider) requestToken(ctx context.Context) (Token, er if err != nil { return Token{}, fmt.Errorf("%s: request access token: %w", serviceAccountKeyErrorPrefix, err) } - defer res.Body.Close() + defer func() { + _ = res.Body.Close() + }() if res.StatusCode != http.StatusOK { bodyRaw, _ := io.ReadAll(res.Body) diff --git a/core/identity/service_account_key_provider_test.go b/core/identity/service_account_key_provider_test.go index 00ab09b59..7116b85dd 100644 --- a/core/identity/service_account_key_provider_test.go +++ b/core/identity/service_account_key_provider_test.go @@ -65,7 +65,7 @@ func TestServiceAccountKeyToken(t *testing.T) { t.Fatalf("marshal service account key: %v", err) } - provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ ServiceAccountKey: string(saKeyJSON), PrivateKey: string(privateKeyPEM), TokenURL: server.URL, @@ -92,7 +92,7 @@ func TestServiceAccountKeyToken(t *testing.T) { } func TestServiceAccountKeyRequiresKeyAndPrivateKey(t *testing.T) { - _, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{}) + _, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{}) if err == nil { t.Fatalf("expected error") } @@ -139,7 +139,7 @@ func TestServiceAccountKeyWithScopes(t *testing.T) { t.Fatalf("marshal service account key: %v", err) } - provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ ServiceAccountKey: string(saKeyJSON), PrivateKey: string(privateKeyPEM), TokenURL: server.URL, @@ -202,7 +202,7 @@ func TestServiceAccountKeyWithResources(t *testing.T) { t.Fatalf("marshal service account key: %v", err) } - provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ ServiceAccountKey: string(saKeyJSON), PrivateKey: string(privateKeyPEM), TokenURL: server.URL, @@ -272,7 +272,7 @@ func TestServiceAccountKeyWithScopesAndResources(t *testing.T) { t.Fatalf("marshal service account key: %v", err) } - provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ ServiceAccountKey: string(saKeyJSON), PrivateKey: string(privateKeyPEM), TokenURL: server.URL, @@ -348,7 +348,7 @@ func TestServiceAccountKeyWithEmptyScopesAndResources(t *testing.T) { t.Fatalf("marshal service account key: %v", err) } - provider, err := NewServiceAccountKeyProvider(ServiceAccountKeyProviderConfig{ + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ ServiceAccountKey: string(saKeyJSON), PrivateKey: string(privateKeyPEM), TokenURL: server.URL, diff --git a/core/identity/static_token_provider.go b/core/identity/static_token_provider.go index 477e0f317..df8c01a1e 100644 --- a/core/identity/static_token_provider.go +++ b/core/identity/static_token_provider.go @@ -6,6 +6,8 @@ import ( "os" ) +const staticTokenErrorPrefix = "static token provider" + var _ TokenProvider = (*StaticTokenProvider)(nil) // StaticTokenProviderConfig contains configuration for StaticTokenProvider. @@ -27,7 +29,10 @@ type StaticTokenProvider struct { } // NewStaticTokenProvider creates a StaticTokenProvider, resolving the token from config or environment. -func NewStaticTokenProvider(cfg StaticTokenProviderConfig) (*StaticTokenProvider, error) { +func NewStaticTokenProvider(cfg *StaticTokenProviderConfig) (*StaticTokenProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", staticTokenErrorPrefix) + } token := cfg.Token if token == "" { if val, found := os.LookupEnv(EnvServiceAccountToken); found && val != "" { diff --git a/core/identity/static_token_provider_test.go b/core/identity/static_token_provider_test.go index fefb22614..0d51cb28d 100644 --- a/core/identity/static_token_provider_test.go +++ b/core/identity/static_token_provider_test.go @@ -6,8 +6,9 @@ import ( ) func TestStaticTokenProvider(t *testing.T) { + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" - provider, err := NewStaticTokenProvider(StaticTokenProviderConfig{Token: accessToken}) + provider, err := NewStaticTokenProvider(&StaticTokenProviderConfig{Token: accessToken}) if err != nil { t.Fatalf("expected no error: %v", err) } @@ -27,7 +28,7 @@ func TestStaticTokenProvider(t *testing.T) { } func TestStaticTokenProviderEmpty(t *testing.T) { - _, err := NewStaticTokenProvider(StaticTokenProviderConfig{Token: ""}) + _, err := NewStaticTokenProvider(&StaticTokenProviderConfig{Token: ""}) if err == nil { t.Fatalf("expected error for empty token") } diff --git a/core/identity/workload_identity_federation_provider.go b/core/identity/workload_identity_federation_provider.go index cf0192e53..5633d101b 100644 --- a/core/identity/workload_identity_federation_provider.go +++ b/core/identity/workload_identity_federation_provider.go @@ -63,7 +63,10 @@ type workloadIdentityTokenResponse struct { } // NewWorkloadIdentityFederationProvider creates a WorkloadIdentityFederation provider. -func NewWorkloadIdentityFederationProvider(cfg WorkloadIdentityFederationProviderConfig) (*WorkloadIdentityFederationProvider, error) { +func NewWorkloadIdentityFederationProvider(cfg *WorkloadIdentityFederationProviderConfig) (*WorkloadIdentityFederationProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", workloadIdentityErrorPrefix) + } tokenURL := cfg.TokenURL if tokenURL == "" { tokenURL = utils.GetEnvOrDefault(EnvIdpTokenEndpoint, WifDefaultTokenEndpoint) @@ -160,7 +163,7 @@ func (p *WorkloadIdentityFederationProvider) requestToken(ctx context.Context) ( if err != nil { return Token{}, fmt.Errorf("%s: request access token: %w", workloadIdentityErrorPrefix, err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { bodyRaw, _ := io.ReadAll(res.Body) diff --git a/core/identity/workload_identity_federation_provider_test.go b/core/identity/workload_identity_federation_provider_test.go index 81975c5cf..58800ef01 100644 --- a/core/identity/workload_identity_federation_provider_test.go +++ b/core/identity/workload_identity_federation_provider_test.go @@ -54,7 +54,7 @@ func TestWorkloadIdentityFederationToken(t *testing.T) { })) defer server.Close() - provider, err := NewWorkloadIdentityFederationProvider(WorkloadIdentityFederationProviderConfig{ + provider, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ TokenURL: server.URL, ClientID: "service-account@sa.stackit.cloud", FederatedTokenFunction: func(context.Context) (string, error) { return assertion, nil }, @@ -81,7 +81,7 @@ func TestWorkloadIdentityFederationToken(t *testing.T) { } func TestWorkloadIdentityFederationRequiresClientID(t *testing.T) { - _, err := NewWorkloadIdentityFederationProvider(WorkloadIdentityFederationProviderConfig{ + _, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ FederatedTokenFunction: func(context.Context) (string, error) { return "token", nil }, }) if err == nil { diff --git a/examples/authentication/go.mod b/examples/authentication/go.mod index ee6b848fe..b3dd08aee 100644 --- a/examples/authentication/go.mod +++ b/examples/authentication/go.mod @@ -5,6 +5,8 @@ go 1.25 // This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub. replace github.com/stackitcloud/stackit-sdk-go/services/dns => ../../services/dns +replace github.com/stackitcloud/stackit-sdk-go/core => ../../core + require ( github.com/stackitcloud/stackit-sdk-go/core v0.26.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.21.0 diff --git a/examples/authentication/go.sum b/examples/authentication/go.sum index 3712a0c87..856cf417c 100644 --- a/examples/authentication/go.sum +++ b/examples/authentication/go.sum @@ -4,5 +4,3 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/stackitcloud/stackit-sdk-go/core v0.26.0 h1:jQEb9gkehfp6VCP6TcYk7BI10cz4l0KM2L6hqYBH2QA= -github.com/stackitcloud/stackit-sdk-go/core v0.26.0/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA= From 1599867c675546b53d3328b7ecba073c4ae34585 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Fri, 10 Jul 2026 15:14:38 +0200 Subject: [PATCH 4/4] linter Signed-off-by: Jorge Turrado --- core/identity/chained_provider.go | 1 + 1 file changed, 1 insertion(+) diff --git a/core/identity/chained_provider.go b/core/identity/chained_provider.go index dd51f2a59..2c0335eba 100644 --- a/core/identity/chained_provider.go +++ b/core/identity/chained_provider.go @@ -56,6 +56,7 @@ func NewChainedProviderWithOptions(options ChainedProviderOptions, providers ... } // NewChainWithOptions creates a chain using the provided options. +// // Deprecated: use NewChainedProviderWithOptions. func NewChainWithOptions(options ChainedProviderOptions, providers ...TokenProvider) (*ChainedProvider, error) { return NewChainedProviderWithOptions(options, providers...)