-
Notifications
You must be signed in to change notification settings - Fork 41
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 |
|---|---|---|
|
|
@@ -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 = "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") | ||
| } | ||
| } |
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).