-
Notifications
You must be signed in to change notification settings - Fork 42
feat(auth): add Workload Identity Federation (OIDC) support #1424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| [email protected] | ||
| # Optional: provide the OIDC token directly instead of auto-detecting it from the CI environment | ||
| STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN=<oidc-token> | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 [email protected] stackit auth activate-service-account --only-print-access-token | ||
| ``` | ||
|
|
||
| ### Options | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,13 +59,22 @@ 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 [email protected] stackit auth activate-service-account --only-print-access-token", | ||
| ), | ||
| ), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| model, err := parseInput(params.Printer, cmd, args) | ||
| if err != nil { | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,6 +101,17 @@ func TestParseInput(t *testing.T) { | |
| model.OnlyPrintAccessToken = false | ||
| }), | ||
| }, | ||
| { | ||
| description: "oidc_mode_no_key_required", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This name is confusing here: OIDC is not set but the name implies that it is set
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, the test does not make too much sense as is.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would keep TestParseInput as is. You already added tests to oidc_test.go to check the correct handling of the oidc flag, right? |
||
| flagValues: map[string]string{}, | ||
| isValid: true, | ||
| expectedModel: &inputModel{ | ||
| ServiceAccountToken: "", | ||
| ServiceAccountKeyPath: "", | ||
| PrivateKeyPath: "", | ||
| OnlyPrintAccessToken: false, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = "[email protected]" | ||
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I checked the TFP implementation against this implementation and found one thing which is different.
The TFP implementation allows to provide a
use_oidcin the provider configuration. If this is set this has higher priority (than the environment variable).See: https://github.com/stackitcloud/terraform-provider-stackit/blob/main/docs/index.md#authentication
In order to use the same mechanism here we need to add a cli flag as well (if provided -> cli flag has prio, if not environment variable is taken).