From 804fbabccb5ae60b0c54150480263c4860d3b331 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Sat, 17 Oct 2026 01:13:30 +0200 Subject: [PATCH] fix: Support reading STACKIT_SERVICE_CONNECTION_ID on Azure Pipelines Signed-off-by: Jorge Turrado --- AUTHENTICATION.md | 2 ++ internal/pkg/auth/oidc.go | 6 ++-- internal/pkg/auth/oidc_test.go | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 21cfd2833..0191e7b8e 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -131,3 +131,5 @@ Using this flow is less secure since the token is long-lived. You can provide th # Optional: provide a file path containing the OIDC token STACKIT_FEDERATED_TOKEN_FILE=/path/to/oidc-token.jwt ``` + + When running in Azure DevOps with the [STACKIT Service Connection](https://marketplace.visualstudio.com/items?itemName=SchwarzDigits.stackit-service-connection), the CLI reads `STACKIT_SERVICE_CONNECTION_ID` and requests the OIDC token for that service connection. diff --git a/internal/pkg/auth/oidc.go b/internal/pkg/auth/oidc.go index 38584687e..0fd07e6d8 100644 --- a/internal/pkg/auth/oidc.go +++ b/internal/pkg/auth/oidc.go @@ -17,6 +17,7 @@ const ( EnvGitHubRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" //nolint:gosec // linter false positive EnvAzureOIDCRequestURI = "SYSTEM_OIDCREQUESTURI" EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive + EnvAzureServiceConnectionID = "STACKIT_SERVICE_CONNECTION_ID" ) func IsOIDCEnabled() bool { @@ -40,7 +41,8 @@ func OIDCServiceAccountEmail() string { // TokenFunc returns the OIDCTokenFunc to use for Workload Identity Federation. // It checks the following token sources in order: STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN, // STACKIT_FEDERATED_TOKEN_FILE, GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL + -// ACTIONS_ID_TOKEN_REQUEST_TOKEN), and Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN). +// ACTIONS_ID_TOKEN_REQUEST_TOKEN), and Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN, +// optionally scoped to the service connection set in STACKIT_SERVICE_CONNECTION_ID). // Returns an error if no source is detected. func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) { // static token provided directly via env var @@ -65,7 +67,7 @@ func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) { // Azure DevOps if adoURL := os.Getenv(EnvAzureOIDCRequestURI); adoURL != "" { if adoToken := os.Getenv(EnvAzureAccessToken); adoToken != "" { - return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, ""), nil + return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, os.Getenv(EnvAzureServiceConnectionID)), nil } } diff --git a/internal/pkg/auth/oidc_test.go b/internal/pkg/auth/oidc_test.go index 12e762980..239e369fd 100644 --- a/internal/pkg/auth/oidc_test.go +++ b/internal/pkg/auth/oidc_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" @@ -169,6 +170,62 @@ func TestTokenFunc_AzureDevOps(t *testing.T) { } } +func TestTokenFunc_AzureDevOps_ServiceConnectionID(t *testing.T) { + tests := []struct { + description string + serviceConnectionID string + }{ + { + description: "service connection id set", + serviceConnectionID: "my-service-connection-id", + }, + { + description: "service connection id unset", + serviceConnectionID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + 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.EnvFederatedTokenFile, "") + t.Setenv(auth.EnvGitHubRequestURL, "") + t.Setenv(auth.EnvGitHubRequestToken, "") + t.Setenv(auth.EnvAzureOIDCRequestURI, srv.URL) + t.Setenv(auth.EnvAzureAccessToken, "ado-access-token") + t.Setenv(auth.EnvAzureServiceConnectionID, tt.serviceConnectionID) + + fn, err := auth.OIDCTokenFunc() + if err != nil { + t.Fatalf("OIDCTokenFunc() unexpected error: %v", err) + } + if _, err := fn(context.Background()); err != nil { + t.Fatalf("fn() unexpected error: %v", err) + } + + got, ok := gotQuery["serviceConnectionId"] + if tt.serviceConnectionID == "" { + if ok { + t.Errorf("serviceConnectionId query param = %q, want it absent", got) + } + return + } + if !ok || len(got) != 1 || got[0] != tt.serviceConnectionID { + t.Errorf("serviceConnectionId query param = %q, want %q", got, tt.serviceConnectionID) + } + }) + } +} + func TestTokenFunc_NoSource(t *testing.T) { // All env vars absent → must return an actionable error, no panic. t.Setenv(auth.EnvServiceAccountFederatedToken, "")