diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 21cfd2833..0e7f30ced 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -131,3 +131,14 @@ 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. + +3. Activate the service account and run commands as usual: + + ```bash + stackit auth activate-service-account --use-oidc + stackit project list + ``` + + As with a service account key, the following commands use the activated service account. Only the service account email is stored: the OIDC token is read from the environment every time a new access token is needed, so the token source must still be available when running those commands (e.g. in the same CI job). 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 aa17b6def..1c1fa9842 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -165,6 +165,11 @@ func runOIDCMode(params *types.CmdParams, model *inputModel) error { } tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey) + if !model.OnlyPrintAccessToken { + if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil { + return err + } + } wifCfg := &sdkConfig.Configuration{ WorkloadIdentityFederation: true, @@ -179,8 +184,8 @@ func runOIDCMode(params *types.CmdParams, model *inputModel) error { return &cliErr.ActivateServiceAccountError{} } - // credentials are never written to disk in OIDC mode - saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true) + // only the service account email is stored, the federated token is read from the environment on every command + saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, model.OnlyPrintAccessToken) if err != nil { var activateErr *cliErr.ActivateServiceAccountError if !errors.As(err, &activateErr) { diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index d50f7b966..ed78fe6a2 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -100,6 +100,16 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print return nil, fmt.Errorf("initialize service account key flow: %w", err) } authCfgOption = sdkConfig.WithCustomAuth(keyFlow) + case AUTH_FLOW_WORKLOAD_IDENTITY: + p.Debug(print.DebugLevel, "authenticating using workload identity federation") + if userSessionExpired { + return nil, fmt.Errorf("session expired") + } + wifFlow, err := initWIFFlowWithStorage() + if err != nil { + return nil, fmt.Errorf("initialize workload identity federation flow: %w", err) + } + authCfgOption = sdkConfig.WithCustomAuth(wifFlow) case AUTH_FLOW_USER_TOKEN: p.Debug(print.DebugLevel, "authenticating using user token") if userSessionExpired { @@ -195,6 +205,15 @@ func GetValidAccessToken(p *print.Printer) (string, error) { return GetAccessToken() } + // For workload identity federation, request a new token if the stored one expired + if flow == AUTH_FLOW_WORKLOAD_IDENTITY { + wifFlow, err := initWIFFlowWithStorage() + if err != nil { + return "", fmt.Errorf("initialize workload identity federation flow: %w", err) + } + return wifFlow.GetAccessToken() + } + if flow != AUTH_FLOW_USER_TOKEN { return "", fmt.Errorf("unsupported authentication flow: %s", flow) } diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go index b3418e731..a56016c35 100644 --- a/internal/pkg/auth/auth_test.go +++ b/internal/pkg/auth/auth_test.go @@ -74,6 +74,8 @@ func TestAuthenticationConfig(t *testing.T) { saKey string privateKeySet bool tokenEndpoint string + saEmail string + federatedToken string isValid bool expectedCustomAuthSet bool expectedTokenSet bool @@ -119,6 +121,41 @@ func TestAuthenticationConfig(t *testing.T) { tokenEndpoint: "token_url", isValid: false, }, + { + description: "base_workload_identity", + flow: AUTH_FLOW_WORKLOAD_IDENTITY, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + saEmail: "ci@sa.stackit.cloud", + federatedToken: "federated_token", + isValid: true, + expectedCustomAuthSet: true, + }, + { + description: "workload_identity_session_expired", + flow: AUTH_FLOW_WORKLOAD_IDENTITY, + sessionExpiresAt: time.Now().Add(-time.Hour), + accessTokenSet: true, + saEmail: "ci@sa.stackit.cloud", + federatedToken: "federated_token", + isValid: false, + }, + { + description: "workload_identity_no_federated_token_source", + flow: AUTH_FLOW_WORKLOAD_IDENTITY, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + saEmail: "ci@sa.stackit.cloud", + isValid: false, + }, + { + description: "workload_identity_no_email", + flow: AUTH_FLOW_WORKLOAD_IDENTITY, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + federatedToken: "federated_token", + isValid: false, + }, { description: "base_user_token", flow: AUTH_FLOW_USER_TOKEN, @@ -156,6 +193,13 @@ 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 + // only the static federated token is used as OIDC token source in these tests + t.Setenv(EnvServiceAccountFederatedToken, tt.federatedToken) + t.Setenv(EnvFederatedTokenFile, "") + t.Setenv(EnvGitHubRequestURL, "") + t.Setenv(EnvGitHubRequestToken, "") + t.Setenv(EnvAzureOIDCRequestURI, "") + t.Setenv(EnvAzureAccessToken, "") keyring.MockInit() timestamp := time.Now().Add(24 * time.Hour) authFields := make(map[authFieldKey]string) @@ -182,6 +226,7 @@ func TestAuthenticationConfig(t *testing.T) { authFields[REFRESH_TOKEN] = tt.refreshToken authFields[SERVICE_ACCOUNT_KEY] = tt.saKey authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint + authFields[SERVICE_ACCOUNT_EMAIL] = tt.saEmail err = SetAuthFlow(tt.flow) if err != nil { 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, "") diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go index dd02dc81d..ee4d19049 100644 --- a/internal/pkg/auth/service_account.go +++ b/internal/pkg/auth/service_account.go @@ -68,15 +68,17 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken case wifFlowInterface: p.Debug(print.DebugLevel, "authenticating using workload identity federation") - authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_KEY + authFlowType = AUTH_FLOW_WORKLOAD_IDENTITY accessToken, err := flow.GetAccessToken() if err != nil { p.Debug(print.ErrorLevel, "get workload identity access token: %v", err) return "", "", &errors.ActivateServiceAccountError{} } + // only the service account email is stored; the federated token is read from the environment on every refresh authFields[ACCESS_TOKEN] = accessToken - disableWriting = true + authFields[SERVICE_ACCOUNT_KEY] = "" + authFields[PRIVATE_KEY] = "" default: return "", "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue") } @@ -149,6 +151,37 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) { return keyFlowWithStorage, nil } +// initWIFFlowWithStorage initializes the workload identity federation flow for the service account stored in the auth storage. +// The federated token is not stored, it is read from the environment every time the access token is refreshed. +func initWIFFlowWithStorage() (*clients.WorkloadIdentityFederationFlow, error) { + authFields := map[authFieldKey]string{ + SERVICE_ACCOUNT_EMAIL: "", + TOKEN_CUSTOM_ENDPOINT: "", + } + err := GetAuthFieldMap(authFields) + if err != nil { + return nil, fmt.Errorf("get from auth storage: %w", err) + } + + tokenFunc, err := OIDCTokenFunc() + if err != nil { + return nil, err + } + + cfg := &clients.WorkloadIdentityFederationFlowConfig{ + TokenUrl: authFields[TOKEN_CUSTOM_ENDPOINT], + ClientID: authFields[SERVICE_ACCOUNT_EMAIL], + FederatedTokenFunction: tokenFunc, + } + + wifFlow := &clients.WorkloadIdentityFederationFlow{} + err = wifFlow.Init(cfg) + if err != nil { + return nil, fmt.Errorf("initialize workload identity federation flow: %w", err) + } + return wifFlow, nil +} + // The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access token func (kf *keyFlowWithStorage) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := kf.keyFlow.RoundTrip(req) diff --git a/internal/pkg/auth/service_account_test.go b/internal/pkg/auth/service_account_test.go index 250285f65..c7b687aab 100644 --- a/internal/pkg/auth/service_account_test.go +++ b/internal/pkg/auth/service_account_test.go @@ -207,9 +207,15 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) { isValid bool }{ { - description: "wif_success_no_credentials_written", + description: "wif_success_credentials_written", accessToken: raw, - disableWriting: false, // even when false, WIF forces no-write internally + disableWriting: false, + isValid: true, + }, + { + description: "wif_success_writing_disabled", + accessToken: raw, + disableWriting: true, isValid: true, }, { @@ -224,6 +230,17 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) { keyring.MockInit() config.InitConfig() + // leftovers from a previous key flow must not survive a WIF activation + if err := SetAuthFieldMap(map[authFieldKey]string{ + SERVICE_ACCOUNT_KEY: "old_key", + PRIVATE_KEY: "old_private_key", + }); err != nil { + t.Fatalf("set previous credentials: %v", err) + } + if err := SetAuthFlow(AUTH_FLOW_SERVICE_ACCOUNT_KEY); err != nil { + t.Fatalf("set previous auth flow: %v", err) + } + flow := &wifFlowMocked{ accessToken: tt.accessToken, getAccessTokenFail: tt.getAccessTokenFail, @@ -245,16 +262,31 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) { 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. + storedFlow, _ := GetAuthFlow() + storedEmail, _ := GetAuthField(SERVICE_ACCOUNT_EMAIL) storedKey, _ := GetAuthField(SERVICE_ACCOUNT_KEY) + storedPrivKey, _ := GetAuthField(PRIVATE_KEY) + + if tt.disableWriting { + if storedFlow != AUTH_FLOW_SERVICE_ACCOUNT_KEY { + t.Errorf("auth flow = %q, want previous flow %q untouched when writing is disabled", storedFlow, AUTH_FLOW_SERVICE_ACCOUNT_KEY) + } + return + } + + // Only the flow and the service account email are needed to rebuild the WIF flow, + // the federated token is read from the environment on every command. + if storedFlow != AUTH_FLOW_WORKLOAD_IDENTITY { + t.Errorf("auth flow = %q, want %q", storedFlow, AUTH_FLOW_WORKLOAD_IDENTITY) + } + if storedEmail != testEmail { + t.Errorf("SERVICE_ACCOUNT_EMAIL = %q, want %q", storedEmail, testEmail) + } if storedKey != "" { - t.Errorf("SERVICE_ACCOUNT_KEY was written to storage in WIF mode, want empty") + t.Errorf("SERVICE_ACCOUNT_KEY = %q, want empty in WIF mode", storedKey) } - storedPrivKey, _ := GetAuthField(PRIVATE_KEY) if storedPrivKey != "" { - t.Errorf("PRIVATE_KEY was written to storage in WIF mode, want empty") + t.Errorf("PRIVATE_KEY = %q, want empty in WIF mode", storedPrivKey) } }) } diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 686a0f677..c5b1cc4ec 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -46,6 +46,7 @@ const ( AUTH_FLOW_USER_TOKEN AuthFlow = "user_token" AUTH_FLOW_SERVICE_ACCOUNT_TOKEN AuthFlow = "sa_token" AUTH_FLOW_SERVICE_ACCOUNT_KEY AuthFlow = "sa_key" + AUTH_FLOW_WORKLOAD_IDENTITY AuthFlow = "sa_wif" ) // Returns all auth field keys managed by the auth storage @@ -335,7 +336,7 @@ func GetProfileEmail(profile string) string { if err != nil { email = "" } - case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY: + case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY, AUTH_FLOW_WORKLOAD_IDENTITY: email, err = getAuthFieldWithProfile(profile, SERVICE_ACCOUNT_EMAIL) if err != nil { email = "" diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index d7320917e..f61fde728 100644 --- a/internal/pkg/auth/storage_test.go +++ b/internal/pkg/auth/storage_test.go @@ -952,6 +952,13 @@ func TestGetProfileEmail(t *testing.T) { authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, expectedEmail: "test@test.com", }, + { + description: "default profile, workload identity", + activeProfile: config.DefaultProfileName, + serviceAccEmail: "test@test.com", + authFlow: AUTH_FLOW_WORKLOAD_IDENTITY, + expectedEmail: "test@test.com", + }, { description: "custom profile, user token", activeProfile: "test-profile",