From 82137f750e8451305fa702ac1b469fb14cbac5b1 Mon Sep 17 00:00:00 2001 From: Philipp Ross Date: Fri, 29 May 2026 22:16:39 +0200 Subject: [PATCH 1/2] feat(auth): add Workload Identity Federation (OIDC) support Allows CI/CD pipelines to authenticate without long-lived service account key files by exchanging a short-lived OIDC token for a STACKIT access token. --- AUTHENTICATION.md | 16 ++ docs/stackit_auth_activate-service-account.md | 3 + .../activate_service_account.go | 57 ++++++ .../activate_service_account_test.go | 11 ++ internal/pkg/auth/auth.go | 34 ++++ internal/pkg/auth/oidc/oidc.go | 78 +++++++++ internal/pkg/auth/oidc/oidc_test.go | 163 ++++++++++++++++++ internal/pkg/auth/service_account.go | 15 ++ internal/pkg/auth/service_account_test.go | 89 ++++++++++ 9 files changed, 466 insertions(+) create mode 100644 internal/pkg/auth/oidc/oidc.go create mode 100644 internal/pkg/auth/oidc/oidc_test.go diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 7da16657f..2f4f60b8f 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -106,3 +106,19 @@ Using this flow is less secure since the token is long-lived. You can provide th 1. Providing the flag `--service-account-token` 2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN` 3. Setting `STACKIT_SERVICE_ACCOUNT_TOKEN` in the credentials file (see above) + +### Workload Identity Federation (OIDC) + +1. Create a service account trusted relation in the STACKIT Portal: + + - Navigate to `Service Accounts` → Select account → `Federated Identity Providers` + - [Configure a Federated Identity Provider](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider) and the required assertions. For detailed assertion configuration per platform, see the [Terraform provider WIF guide](https://github.com/stackitcloud/terraform-provider-stackit/blob/main/docs/guides/workload_identity_federation.md). + +2. Configure authentication using environment variables: + + ```bash + STACKIT_USE_OIDC=1 + STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa.stackit.cloud + # Optional: provide the OIDC token directly instead of auto-detecting it from the CI environment + STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN= + ``` diff --git a/docs/stackit_auth_activate-service-account.md b/docs/stackit_auth_activate-service-account.md index 3d154ebfb..83af0bb8e 100644 --- a/docs/stackit_auth_activate-service-account.md +++ b/docs/stackit_auth_activate-service-account.md @@ -26,6 +26,9 @@ stackit auth activate-service-account [flags] Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands. $ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token + + Authenticate via Workload Identity Federation (OIDC) and print the short-lived access token. Set STACKIT_USE_OIDC=1 and STACKIT_SERVICE_ACCOUNT_EMAIL; no service account key file is required. + $ STACKIT_USE_OIDC=1 STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --only-print-access-token ``` ### Options diff --git a/internal/cmd/auth/activate-service-account/activate_service_account.go b/internal/cmd/auth/activate-service-account/activate_service_account.go index b69de90d7..e52634241 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -10,6 +10,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" "github.com/stackitcloud/stackit-cli/internal/pkg/config" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -59,6 +60,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands.`, "$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token", ), + examples.NewExample( + `Authenticate via Workload Identity Federation (OIDC) and print the short-lived access token. Set STACKIT_USE_OIDC=1 and STACKIT_SERVICE_ACCOUNT_EMAIL; no service account key file is required.`, + "$ STACKIT_USE_OIDC=1 STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --only-print-access-token", + ), ), RunE: func(cmd *cobra.Command, args []string) error { model, err := parseInput(params.Printer, cmd, args) @@ -66,6 +71,11 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return err } + // use workload identity federation (OIDC) if enabled; no key file required + if oidc.IsEnabled() { + return runOIDCMode(params, model) + } + tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey) if !model.OnlyPrintAccessToken { if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil { @@ -133,3 +143,50 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, func storeCustomEndpoint(tokenCustomEndpoint string) error { return auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint) } + +func runOIDCMode(params *types.CmdParams, model *inputModel) error { + email := oidc.ServiceAccountEmail() + if email == "" { + return fmt.Errorf( + "env var %s must be set when %s is enabled", + oidc.EnvServiceAccountEmail, oidc.EnvUseOIDC, + ) + } + + tokenFunc, err := oidc.TokenFunc() + if err != nil { + return err + } + + tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey) + + wifCfg := &sdkConfig.Configuration{ + WorkloadIdentityFederation: true, + ServiceAccountEmail: email, + ServiceAccountFederatedTokenFunc: tokenFunc, + TokenCustomUrl: tokenCustomEndpoint, + } + + rt, err := sdkAuth.SetupAuth(wifCfg) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "setup workload identity federation auth: %v", err) + return &cliErr.ActivateServiceAccountError{} + } + + // credentials are never written to disk in OIDC mode + saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true) + if err != nil { + var activateErr *cliErr.ActivateServiceAccountError + if !errors.As(err, &activateErr) { + return fmt.Errorf("authenticate service account via workload identity federation: %w", err) + } + return err + } + + if model.OnlyPrintAccessToken { + params.Printer.Outputf("%s\n", accessToken) + } else { + params.Printer.Outputf("Authenticated via Workload Identity Federation.\nService account email: %s\n", saEmail) + } + return nil +} diff --git a/internal/cmd/auth/activate-service-account/activate_service_account_test.go b/internal/cmd/auth/activate-service-account/activate_service_account_test.go index 22a777ac4..1c2242157 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account_test.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account_test.go @@ -101,6 +101,17 @@ func TestParseInput(t *testing.T) { model.OnlyPrintAccessToken = false }), }, + { + description: "oidc_mode_no_key_required", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + ServiceAccountToken: "", + ServiceAccountKeyPath: "", + PrivateKeyPath: "", + OnlyPrintAccessToken: false, + }, + }, } for _, tt := range tests { diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index 685a1737a..6cc6aeffa 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -7,11 +7,13 @@ import ( "strconv" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/golang-jwt/jwt/v5" "github.com/spf13/viper" + sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth" sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) @@ -33,6 +35,38 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print return authCfgOption, nil } + // use workload identity federation (OIDC) if enabled; takes priority over stored flows + if oidc.IsEnabled() { + p.Debug(print.DebugLevel, "authenticating using workload identity federation (OIDC)") + + email := oidc.ServiceAccountEmail() + if email == "" { + return nil, fmt.Errorf( + "env var %s must be set when %s is enabled", + oidc.EnvServiceAccountEmail, oidc.EnvUseOIDC, + ) + } + + tokenFunc, err := oidc.TokenFunc() + if err != nil { + return nil, err + } + + wifCfg := &sdkConfig.Configuration{ + WorkloadIdentityFederation: true, + ServiceAccountEmail: email, + ServiceAccountFederatedTokenFunc: tokenFunc, + TokenCustomUrl: viper.GetString(config.TokenCustomEndpointKey), + } + + rt, err := sdkAuth.WorkloadIdentityFederationAuth(wifCfg) + if err != nil { + return nil, fmt.Errorf("initialize workload identity federation: %w", err) + } + + return sdkConfig.WithCustomAuth(rt), nil + } + flow, err := GetAuthFlow() if err != nil { return nil, fmt.Errorf("get authentication flow: %w", err) diff --git a/internal/pkg/auth/oidc/oidc.go b/internal/pkg/auth/oidc/oidc.go new file mode 100644 index 000000000..c85b5d108 --- /dev/null +++ b/internal/pkg/auth/oidc/oidc.go @@ -0,0 +1,78 @@ +package oidc + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" +) + +const ( + EnvUseOIDC = "STACKIT_USE_OIDC" + EnvServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" + EnvServiceAccountFederatedToken = "STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN" //nolint:gosec // linter false positive + EnvGitHubRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL" + EnvGitHubRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" //nolint:gosec // linter false positive + EnvAzureOIDCRequestURI = "SYSTEM_OIDCREQUESTURI" + EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive +) + +// IsEnabled returns true if STACKIT_USE_OIDC is set to a truthy value +// ("1", "true", or "yes", case-insensitive). +func IsEnabled() bool { + return isTruthy(os.Getenv(EnvUseOIDC)) +} + +// ServiceAccountEmail returns the value of the STACKIT_SERVICE_ACCOUNT_EMAIL +// environment variable. +func ServiceAccountEmail() string { + return os.Getenv(EnvServiceAccountEmail) +} + +// TokenFunc returns the OIDCTokenFunc to use for Workload Identity Federation. +// It checks the following token sources in order: STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN, +// GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL + ACTIONS_ID_TOKEN_REQUEST_TOKEN), and +// Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN). +// Returns an error if no source is detected. +func TokenFunc() (oidcadapters.OIDCTokenFunc, error) { + // static token provided directly via env var + if token := os.Getenv(EnvServiceAccountFederatedToken); token != "" { + return func(_ context.Context) (string, error) { + return token, nil + }, nil + } + + // GitHub Actions + if ghURL := os.Getenv(EnvGitHubRequestURL); ghURL != "" { + if ghToken := os.Getenv(EnvGitHubRequestToken); ghToken != "" { + return oidcadapters.RequestGHOIDCToken(ghURL, ghToken), nil + } + } + + // Azure DevOps + if adoURL := os.Getenv(EnvAzureOIDCRequestURI); adoURL != "" { + if adoToken := os.Getenv(EnvAzureAccessToken); adoToken != "" { + return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, ""), nil + } + } + + return nil, fmt.Errorf( + "%s is enabled but no OIDC token source was detected\n"+ + "Provide the token via %s, or run in a supported CI environment:\n"+ + " - GitHub Actions: grant 'id-token: write' permission; %s and %s are set automatically by the runner\n"+ + " - Azure DevOps: pass 'SYSTEM_ACCESSTOKEN: $(System.AccessToken)' in your pipeline step", + EnvUseOIDC, EnvServiceAccountFederatedToken, + EnvGitHubRequestURL, EnvGitHubRequestToken, + ) +} + +// isTruthy returns true for "1", "true", "yes" (case-insensitive). +func isTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes": + return true + } + return false +} diff --git a/internal/pkg/auth/oidc/oidc_test.go b/internal/pkg/auth/oidc/oidc_test.go new file mode 100644 index 000000000..dfc1c702c --- /dev/null +++ b/internal/pkg/auth/oidc/oidc_test.go @@ -0,0 +1,163 @@ +package oidc_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" +) + +func TestIsEnabled(t *testing.T) { + tests := []struct { + value string + expected bool + }{ + {"1", true}, + {"true", true}, + {"True", true}, + {"TRUE", true}, + {"yes", true}, + {"YES", true}, + {"Yes", true}, + {"0", false}, + {"false", false}, + {"no", false}, + {"", false}, + {"random", false}, + {" 1 ", true}, // leading/trailing whitespace + {" true", true}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + t.Setenv(oidc.EnvUseOIDC, tt.value) + got := oidc.IsEnabled() + if got != tt.expected { + t.Errorf("IsEnabled() = %v, want %v (env=%q)", got, tt.expected, tt.value) + } + }) + } +} + +func TestIsEnabled_Unset(t *testing.T) { + // When the env var is not set at all IsEnabled must return false + t.Setenv(oidc.EnvUseOIDC, "") + if oidc.IsEnabled() { + t.Error("IsEnabled() = true, want false when env var is empty") + } +} + +func TestServiceAccountEmail(t *testing.T) { + const want = "ci@sa.stackit.cloud" + t.Setenv(oidc.EnvServiceAccountEmail, want) + if got := oidc.ServiceAccountEmail(); got != want { + t.Errorf("ServiceAccountEmail() = %q, want %q", got, want) + } +} + +func TestTokenFunc_StaticToken(t *testing.T) { + const want = "my-static-oidc-token" + t.Setenv(oidc.EnvServiceAccountFederatedToken, want) + // ensure GitHub / Azure vars are absent so we hit the static path first + t.Setenv(oidc.EnvGitHubRequestURL, "") + t.Setenv(oidc.EnvGitHubRequestToken, "") + t.Setenv(oidc.EnvAzureOIDCRequestURI, "") + t.Setenv(oidc.EnvAzureAccessToken, "") + + fn, err := oidc.TokenFunc() + if err != nil { + t.Fatalf("TokenFunc() unexpected error: %v", err) + } + got, err := fn(context.Background()) + if err != nil { + t.Fatalf("fn() unexpected error: %v", err) + } + if got != want { + t.Errorf("fn() = %q, want %q", got, want) + } +} + +func TestTokenFunc_GitHubActions(t *testing.T) { + // Spin up a fake GitHub OIDC endpoint that matches the SDK's expected format. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"value": "gh-oidc-token"}) + })) + defer srv.Close() + + t.Setenv(oidc.EnvServiceAccountFederatedToken, "") + t.Setenv(oidc.EnvGitHubRequestURL, srv.URL) + t.Setenv(oidc.EnvGitHubRequestToken, "gh-bearer-token") + t.Setenv(oidc.EnvAzureOIDCRequestURI, "") + t.Setenv(oidc.EnvAzureAccessToken, "") + + fn, err := oidc.TokenFunc() + if err != nil { + t.Fatalf("TokenFunc() unexpected error: %v", err) + } + got, err := fn(context.Background()) + if err != nil { + t.Fatalf("fn() unexpected error: %v", err) + } + if got != "gh-oidc-token" { + t.Errorf("fn() = %q, want %q", got, "gh-oidc-token") + } +} + +func TestTokenFunc_AzureDevOps(t *testing.T) { + // Spin up a fake Azure DevOps OIDC endpoint that matches the SDK's expected format. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + tok := "ado-oidc-token" + _ = json.NewEncoder(w).Encode(map[string]*string{"oidcToken": &tok}) + })) + defer srv.Close() + + t.Setenv(oidc.EnvServiceAccountFederatedToken, "") + t.Setenv(oidc.EnvGitHubRequestURL, "") + t.Setenv(oidc.EnvGitHubRequestToken, "") + t.Setenv(oidc.EnvAzureOIDCRequestURI, srv.URL) + t.Setenv(oidc.EnvAzureAccessToken, "ado-access-token") + + fn, err := oidc.TokenFunc() + if err != nil { + t.Fatalf("TokenFunc() unexpected error: %v", err) + } + got, err := fn(context.Background()) + if err != nil { + t.Fatalf("fn() unexpected error: %v", err) + } + if got != "ado-oidc-token" { + t.Errorf("fn() = %q, want %q", got, "ado-oidc-token") + } +} + +func TestTokenFunc_NoSource(t *testing.T) { + // All env vars absent → must return an actionable error, no panic. + t.Setenv(oidc.EnvServiceAccountFederatedToken, "") + t.Setenv(oidc.EnvGitHubRequestURL, "") + t.Setenv(oidc.EnvGitHubRequestToken, "") + t.Setenv(oidc.EnvAzureOIDCRequestURI, "") + t.Setenv(oidc.EnvAzureAccessToken, "") + + _, err := oidc.TokenFunc() + if err == nil { + t.Fatal("TokenFunc() expected error when no OIDC source is available, got nil") + } +} + +func TestTokenFunc_GitHubURL_NoToken(t *testing.T) { + // URL present but token absent → should fall through to Azure / error. + t.Setenv(oidc.EnvServiceAccountFederatedToken, "") + t.Setenv(oidc.EnvGitHubRequestURL, "https://example.com") + t.Setenv(oidc.EnvGitHubRequestToken, "") + t.Setenv(oidc.EnvAzureOIDCRequestURI, "") + t.Setenv(oidc.EnvAzureAccessToken, "") + + _, err := oidc.TokenFunc() + if err == nil { + t.Fatal("TokenFunc() expected error when GitHub token is missing, got nil") + } +} diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go index 8aecaf0b4..dd02dc81d 100644 --- a/internal/pkg/auth/service_account.go +++ b/internal/pkg/auth/service_account.go @@ -22,6 +22,10 @@ type tokenFlowInterface interface { RoundTrip(*http.Request) (*http.Response, error) } +type wifFlowInterface interface { + GetAccessToken() (string, error) +} + type keyFlowWithStorage struct { keyFlow *clients.KeyFlow } @@ -62,6 +66,17 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_TOKEN authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken + case wifFlowInterface: + p.Debug(print.DebugLevel, "authenticating using workload identity federation") + authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_KEY + + accessToken, err := flow.GetAccessToken() + if err != nil { + p.Debug(print.ErrorLevel, "get workload identity access token: %v", err) + return "", "", &errors.ActivateServiceAccountError{} + } + authFields[ACCESS_TOKEN] = accessToken + disableWriting = true default: return "", "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue") } diff --git a/internal/pkg/auth/service_account_test.go b/internal/pkg/auth/service_account_test.go index 328fb6e48..250285f65 100644 --- a/internal/pkg/auth/service_account_test.go +++ b/internal/pkg/auth/service_account_test.go @@ -66,6 +66,22 @@ func (f *tokenFlowMocked) RoundTrip(*http.Request) (*http.Response, error) { return nil, nil } +type wifFlowMocked struct { + accessToken string + getAccessTokenFail bool +} + +func (f *wifFlowMocked) GetAccessToken() (string, error) { + if f.getAccessTokenFail { + return "", fmt.Errorf("mock WIF error") + } + return f.accessToken, nil +} + +func (f *wifFlowMocked) RoundTrip(*http.Request) (*http.Response, error) { + return nil, nil +} + func TestAuthenticateServiceAccount(t *testing.T) { tests := []struct { description string @@ -170,3 +186,76 @@ func TestAuthenticateServiceAccount(t *testing.T) { }) } } + +func TestAuthenticateServiceAccount_WIF(t *testing.T) { + // Build a signed test JWT that getEmailFromToken can parse. + testEmail := "ci@sa.stackit.cloud" + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &tokenClaims{ + Email: testEmail, + RegisteredClaims: jwt.RegisteredClaims{}, + }) + raw, err := tok.SignedString(accessTokenSigningKey) + if err != nil { + t.Fatalf("sign test token: %v", err) + } + + tests := []struct { + description string + accessToken string + getAccessTokenFail bool + disableWriting bool + isValid bool + }{ + { + description: "wif_success_no_credentials_written", + accessToken: raw, + disableWriting: false, // even when false, WIF forces no-write internally + isValid: true, + }, + { + description: "wif_get_access_token_fails", + getAccessTokenFail: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + config.InitConfig() + + flow := &wifFlowMocked{ + accessToken: tt.accessToken, + getAccessTokenFail: tt.getAccessTokenFail, + } + + params := testparams.NewTestParams() + email, _, err := AuthenticateServiceAccount(params.Printer, flow, tt.disableWriting) + + if !tt.isValid { + if err == nil { + t.Fatal("Expected error but no error was returned") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if email != testEmail { + t.Fatalf("email = %q, want %q", email, testEmail) + } + + // Verify no credentials were written to the keyring / file. + // After a WIF authentication, the auth storage should not contain a + // service account key or private key. + storedKey, _ := GetAuthField(SERVICE_ACCOUNT_KEY) + if storedKey != "" { + t.Errorf("SERVICE_ACCOUNT_KEY was written to storage in WIF mode, want empty") + } + storedPrivKey, _ := GetAuthField(PRIVATE_KEY) + if storedPrivKey != "" { + t.Errorf("PRIVATE_KEY was written to storage in WIF mode, want empty") + } + }) + } +} From f0d60b01dd1fc14412eb1f529b78a88c2853d11f Mon Sep 17 00:00:00 2001 From: Philipp Ross Date: Mon, 1 Jun 2026 11:56:26 +0200 Subject: [PATCH 2/2] refactor(auth): move OIDC helpers into auth package --- .../activate_service_account.go | 9 +- internal/pkg/auth/auth.go | 9 +- internal/pkg/auth/auth_test.go | 1 + internal/pkg/auth/{oidc => }/oidc.go | 24 +--- internal/pkg/auth/{oidc => }/oidc_test.go | 104 ++++++++---------- 5 files changed, 62 insertions(+), 85 deletions(-) rename internal/pkg/auth/{oidc => }/oidc.go (78%) rename internal/pkg/auth/{oidc => }/oidc_test.go (50%) diff --git a/internal/cmd/auth/activate-service-account/activate_service_account.go b/internal/cmd/auth/activate-service-account/activate_service_account.go index e52634241..3a43f313c 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -10,7 +10,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" - "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" "github.com/stackitcloud/stackit-cli/internal/pkg/config" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -72,7 +71,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // use workload identity federation (OIDC) if enabled; no key file required - if oidc.IsEnabled() { + if auth.IsOIDCEnabled() { return runOIDCMode(params, model) } @@ -145,15 +144,15 @@ func storeCustomEndpoint(tokenCustomEndpoint string) error { } func runOIDCMode(params *types.CmdParams, model *inputModel) error { - email := oidc.ServiceAccountEmail() + email := auth.OIDCServiceAccountEmail() if email == "" { return fmt.Errorf( "env var %s must be set when %s is enabled", - oidc.EnvServiceAccountEmail, oidc.EnvUseOIDC, + auth.EnvServiceAccountEmail, auth.EnvUseOIDC, ) } - tokenFunc, err := oidc.TokenFunc() + tokenFunc, err := auth.OIDCTokenFunc() if err != nil { return err } diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index 6cc6aeffa..3d241ed9a 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -36,18 +35,18 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print } // use workload identity federation (OIDC) if enabled; takes priority over stored flows - if oidc.IsEnabled() { + if IsOIDCEnabled() { p.Debug(print.DebugLevel, "authenticating using workload identity federation (OIDC)") - email := oidc.ServiceAccountEmail() + email := OIDCServiceAccountEmail() if email == "" { return nil, fmt.Errorf( "env var %s must be set when %s is enabled", - oidc.EnvServiceAccountEmail, oidc.EnvUseOIDC, + EnvServiceAccountEmail, EnvUseOIDC, ) } - tokenFunc, err := oidc.TokenFunc() + tokenFunc, err := OIDCTokenFunc() if err != nil { return nil, err } diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go index e29b482f9..192645d21 100644 --- a/internal/pkg/auth/auth_test.go +++ b/internal/pkg/auth/auth_test.go @@ -155,6 +155,7 @@ func TestAuthenticationConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { + t.Setenv(EnvUseOIDC, "") // ensure OIDC mode is off for these tests keyring.MockInit() timestamp := time.Now().Add(24 * time.Hour) authFields := make(map[authFieldKey]string) diff --git a/internal/pkg/auth/oidc/oidc.go b/internal/pkg/auth/oidc.go similarity index 78% rename from internal/pkg/auth/oidc/oidc.go rename to internal/pkg/auth/oidc.go index c85b5d108..29191eb9e 100644 --- a/internal/pkg/auth/oidc/oidc.go +++ b/internal/pkg/auth/oidc.go @@ -1,10 +1,9 @@ -package oidc +package auth import ( "context" "fmt" "os" - "strings" "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" ) @@ -19,15 +18,11 @@ const ( EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive ) -// IsEnabled returns true if STACKIT_USE_OIDC is set to a truthy value -// ("1", "true", or "yes", case-insensitive). -func IsEnabled() bool { - return isTruthy(os.Getenv(EnvUseOIDC)) +func IsOIDCEnabled() bool { + return os.Getenv(EnvUseOIDC) == "1" } -// ServiceAccountEmail returns the value of the STACKIT_SERVICE_ACCOUNT_EMAIL -// environment variable. -func ServiceAccountEmail() string { +func OIDCServiceAccountEmail() string { return os.Getenv(EnvServiceAccountEmail) } @@ -36,7 +31,7 @@ func ServiceAccountEmail() string { // GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL + ACTIONS_ID_TOKEN_REQUEST_TOKEN), and // Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN). // Returns an error if no source is detected. -func TokenFunc() (oidcadapters.OIDCTokenFunc, error) { +func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) { // static token provided directly via env var if token := os.Getenv(EnvServiceAccountFederatedToken); token != "" { return func(_ context.Context) (string, error) { @@ -67,12 +62,3 @@ func TokenFunc() (oidcadapters.OIDCTokenFunc, error) { EnvGitHubRequestURL, EnvGitHubRequestToken, ) } - -// isTruthy returns true for "1", "true", "yes" (case-insensitive). -func isTruthy(s string) bool { - switch strings.ToLower(strings.TrimSpace(s)) { - case "1", "true", "yes": - return true - } - return false -} diff --git a/internal/pkg/auth/oidc/oidc_test.go b/internal/pkg/auth/oidc_test.go similarity index 50% rename from internal/pkg/auth/oidc/oidc_test.go rename to internal/pkg/auth/oidc_test.go index dfc1c702c..ea1bc8b74 100644 --- a/internal/pkg/auth/oidc/oidc_test.go +++ b/internal/pkg/auth/oidc_test.go @@ -1,4 +1,4 @@ -package oidc_test +package auth_test import ( "context" @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/auth/oidc" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" ) func TestIsEnabled(t *testing.T) { @@ -16,26 +16,18 @@ func TestIsEnabled(t *testing.T) { expected bool }{ {"1", true}, - {"true", true}, - {"True", true}, - {"TRUE", true}, - {"yes", true}, - {"YES", true}, - {"Yes", true}, {"0", false}, - {"false", false}, - {"no", false}, {"", false}, + {"true", false}, + {"yes", false}, {"random", false}, - {" 1 ", true}, // leading/trailing whitespace - {" true", true}, } for _, tt := range tests { t.Run(tt.value, func(t *testing.T) { - t.Setenv(oidc.EnvUseOIDC, tt.value) - got := oidc.IsEnabled() + t.Setenv(auth.EnvUseOIDC, tt.value) + got := auth.IsOIDCEnabled() if got != tt.expected { - t.Errorf("IsEnabled() = %v, want %v (env=%q)", got, tt.expected, tt.value) + t.Errorf("IsOIDCEnabled() = %v, want %v (env=%q)", got, tt.expected, tt.value) } }) } @@ -43,32 +35,32 @@ func TestIsEnabled(t *testing.T) { func TestIsEnabled_Unset(t *testing.T) { // When the env var is not set at all IsEnabled must return false - t.Setenv(oidc.EnvUseOIDC, "") - if oidc.IsEnabled() { - t.Error("IsEnabled() = true, want false when env var is empty") + t.Setenv(auth.EnvUseOIDC, "") + if auth.IsOIDCEnabled() { + t.Error("IsOIDCEnabled() = true, want false when env var is empty") } } func TestServiceAccountEmail(t *testing.T) { const want = "ci@sa.stackit.cloud" - t.Setenv(oidc.EnvServiceAccountEmail, want) - if got := oidc.ServiceAccountEmail(); got != want { - t.Errorf("ServiceAccountEmail() = %q, want %q", got, want) + t.Setenv(auth.EnvServiceAccountEmail, want) + if got := auth.OIDCServiceAccountEmail(); got != want { + t.Errorf("OIDCServiceAccountEmail() = %q, want %q", got, want) } } func TestTokenFunc_StaticToken(t *testing.T) { const want = "my-static-oidc-token" - t.Setenv(oidc.EnvServiceAccountFederatedToken, want) + t.Setenv(auth.EnvServiceAccountFederatedToken, want) // ensure GitHub / Azure vars are absent so we hit the static path first - t.Setenv(oidc.EnvGitHubRequestURL, "") - t.Setenv(oidc.EnvGitHubRequestToken, "") - t.Setenv(oidc.EnvAzureOIDCRequestURI, "") - t.Setenv(oidc.EnvAzureAccessToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") - fn, err := oidc.TokenFunc() + fn, err := auth.OIDCTokenFunc() if err != nil { - t.Fatalf("TokenFunc() unexpected error: %v", err) + t.Fatalf("OIDCTokenFunc() unexpected error: %v", err) } got, err := fn(context.Background()) if err != nil { @@ -87,15 +79,15 @@ func TestTokenFunc_GitHubActions(t *testing.T) { })) defer srv.Close() - t.Setenv(oidc.EnvServiceAccountFederatedToken, "") - t.Setenv(oidc.EnvGitHubRequestURL, srv.URL) - t.Setenv(oidc.EnvGitHubRequestToken, "gh-bearer-token") - t.Setenv(oidc.EnvAzureOIDCRequestURI, "") - t.Setenv(oidc.EnvAzureAccessToken, "") + t.Setenv(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, srv.URL) + t.Setenv(auth.EnvGitHubRequestToken, "gh-bearer-token") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") - fn, err := oidc.TokenFunc() + fn, err := auth.OIDCTokenFunc() if err != nil { - t.Fatalf("TokenFunc() unexpected error: %v", err) + t.Fatalf("OIDCTokenFunc() unexpected error: %v", err) } got, err := fn(context.Background()) if err != nil { @@ -115,15 +107,15 @@ func TestTokenFunc_AzureDevOps(t *testing.T) { })) defer srv.Close() - t.Setenv(oidc.EnvServiceAccountFederatedToken, "") - t.Setenv(oidc.EnvGitHubRequestURL, "") - t.Setenv(oidc.EnvGitHubRequestToken, "") - t.Setenv(oidc.EnvAzureOIDCRequestURI, srv.URL) - t.Setenv(oidc.EnvAzureAccessToken, "ado-access-token") + t.Setenv(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, srv.URL) + t.Setenv(auth.EnvAzureAccessToken, "ado-access-token") - fn, err := oidc.TokenFunc() + fn, err := auth.OIDCTokenFunc() if err != nil { - t.Fatalf("TokenFunc() unexpected error: %v", err) + t.Fatalf("OIDCTokenFunc() unexpected error: %v", err) } got, err := fn(context.Background()) if err != nil { @@ -136,28 +128,28 @@ func TestTokenFunc_AzureDevOps(t *testing.T) { func TestTokenFunc_NoSource(t *testing.T) { // All env vars absent → must return an actionable error, no panic. - t.Setenv(oidc.EnvServiceAccountFederatedToken, "") - t.Setenv(oidc.EnvGitHubRequestURL, "") - t.Setenv(oidc.EnvGitHubRequestToken, "") - t.Setenv(oidc.EnvAzureOIDCRequestURI, "") - t.Setenv(oidc.EnvAzureAccessToken, "") + t.Setenv(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") - _, err := oidc.TokenFunc() + _, err := auth.OIDCTokenFunc() if err == nil { - t.Fatal("TokenFunc() expected error when no OIDC source is available, got nil") + t.Fatal("OIDCTokenFunc() expected error when no OIDC source is available, got nil") } } func TestTokenFunc_GitHubURL_NoToken(t *testing.T) { // URL present but token absent → should fall through to Azure / error. - t.Setenv(oidc.EnvServiceAccountFederatedToken, "") - t.Setenv(oidc.EnvGitHubRequestURL, "https://example.com") - t.Setenv(oidc.EnvGitHubRequestToken, "") - t.Setenv(oidc.EnvAzureOIDCRequestURI, "") - t.Setenv(oidc.EnvAzureAccessToken, "") + t.Setenv(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "https://example.com") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") - _, err := oidc.TokenFunc() + _, err := auth.OIDCTokenFunc() if err == nil { - t.Fatal("TokenFunc() expected error when GitHub token is missing, got nil") + t.Fatal("OIDCTokenFunc() expected error when GitHub token is missing, got nil") } }