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..3a43f313c 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -59,6 +59,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 +70,11 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return err } + // use workload identity federation (OIDC) if enabled; no key file required + if auth.IsOIDCEnabled() { + return runOIDCMode(params, model) + } + tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey) if !model.OnlyPrintAccessToken { if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil { @@ -133,3 +142,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 := auth.OIDCServiceAccountEmail() + if email == "" { + return fmt.Errorf( + "env var %s must be set when %s is enabled", + auth.EnvServiceAccountEmail, auth.EnvUseOIDC, + ) + } + + tokenFunc, err := auth.OIDCTokenFunc() + 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..3d241ed9a 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -12,6 +12,7 @@ import ( "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 +34,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 IsOIDCEnabled() { + p.Debug(print.DebugLevel, "authenticating using workload identity federation (OIDC)") + + email := OIDCServiceAccountEmail() + if email == "" { + return nil, fmt.Errorf( + "env var %s must be set when %s is enabled", + EnvServiceAccountEmail, EnvUseOIDC, + ) + } + + tokenFunc, err := OIDCTokenFunc() + 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/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.go b/internal/pkg/auth/oidc.go new file mode 100644 index 000000000..29191eb9e --- /dev/null +++ b/internal/pkg/auth/oidc.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "fmt" + "os" + + "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 +) + +func IsOIDCEnabled() bool { + return os.Getenv(EnvUseOIDC) == "1" +} + +func OIDCServiceAccountEmail() 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 OIDCTokenFunc() (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, + ) +} diff --git a/internal/pkg/auth/oidc_test.go b/internal/pkg/auth/oidc_test.go new file mode 100644 index 000000000..ea1bc8b74 --- /dev/null +++ b/internal/pkg/auth/oidc_test.go @@ -0,0 +1,155 @@ +package auth_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" +) + +func TestIsEnabled(t *testing.T) { + tests := []struct { + value string + expected bool + }{ + {"1", true}, + {"0", false}, + {"", false}, + {"true", false}, + {"yes", false}, + {"random", false}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + t.Setenv(auth.EnvUseOIDC, tt.value) + got := auth.IsOIDCEnabled() + if got != tt.expected { + t.Errorf("IsOIDCEnabled() = %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(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(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(auth.EnvServiceAccountFederatedToken, want) + // ensure GitHub / Azure vars are absent so we hit the static path first + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") + + fn, err := auth.OIDCTokenFunc() + if err != nil { + t.Fatalf("OIDCTokenFunc() 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(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 := auth.OIDCTokenFunc() + if err != nil { + t.Fatalf("OIDCTokenFunc() 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(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 := auth.OIDCTokenFunc() + if err != nil { + t.Fatalf("OIDCTokenFunc() 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(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") + + _, err := auth.OIDCTokenFunc() + if err == 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(auth.EnvServiceAccountFederatedToken, "") + t.Setenv(auth.EnvGitHubRequestURL, "https://example.com") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, "") + t.Setenv(auth.EnvAzureAccessToken, "") + + _, err := auth.OIDCTokenFunc() + if err == nil { + t.Fatal("OIDCTokenFunc() 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") + } + }) + } +}