From 3c7e21d5e9980a884c617c3254b6c1b9ebcf9a71 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Mon, 26 Oct 2026 23:04:29 +0100 Subject: [PATCH] fix(auth): support env-based authentication in commands reading the stored session Closes #1703 Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/organization/list/list.go | 6 +- internal/pkg/auth/auth.go | 82 +++++++++++---- internal/pkg/auth/auth_test.go | 138 +++++++++++++++++++++++++ internal/pkg/auth/storage.go | 7 ++ internal/pkg/auth/storage_test.go | 15 +++ 5 files changed, 223 insertions(+), 25 deletions(-) diff --git a/internal/cmd/organization/list/list.go b/internal/cmd/organization/list/list.go index 49b62c956..3252cee47 100644 --- a/internal/cmd/organization/list/list.go +++ b/internal/cmd/organization/list/list.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/spf13/cobra" @@ -54,11 +53,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { return err } - activeProfile, err := config.GetProfile() + model.Member, err = auth.GetAuthEmail() if err != nil { - return fmt.Errorf("get profile: %w", err) + return fmt.Errorf("get email of authenticated user: %w", err) } - model.Member = auth.GetProfileEmail(activeProfile) // Configure API client apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index d50f7b966..9ca8199c5 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -38,31 +38,11 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print 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() + rt, err := oidcRoundTripper() 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 } @@ -119,11 +99,21 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print return authCfgOption, nil } +// UserSessionExpired checks whether the stored session has expired. +// Authentication via environment variables (STACKIT_ACCESS_TOKEN or OIDC) does not use a stored session, +// so it never counts as expired. The same applies if no session expiration is stored. func UserSessionExpired() (bool, error) { + if os.Getenv(envAccessTokenName) != "" || IsOIDCEnabled() { + return false, nil + } + sessionExpiresAtString, err := GetAuthField(SESSION_EXPIRES_AT_UNIX) if err != nil { return false, fmt.Errorf("get %s: %w", SESSION_EXPIRES_AT_UNIX, err) } + if sessionExpiresAtString == "" { + return false, nil + } sessionExpiresAtInt, err := strconv.Atoi(sessionExpiresAtString) if err != nil { return false, fmt.Errorf("parse session expiration value \"%s\": %w", sessionExpiresAtString, err) @@ -166,6 +156,48 @@ func getSessionExpiration() (time.Duration, error) { return duration, nil } +// oidcRoundTripper initializes the workload identity federation (OIDC) authentication flow from the environment. +func oidcRoundTripper() (http.RoundTripper, error) { + 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 + } + + rt, err := sdkAuth.WorkloadIdentityFederationAuth(&sdkConfig.Configuration{ + WorkloadIdentityFederation: true, + ServiceAccountEmail: email, + ServiceAccountFederatedTokenFunc: tokenFunc, + TokenCustomUrl: viper.GetString(config.TokenCustomEndpointKey), + }) + if err != nil { + return nil, fmt.Errorf("initialize workload identity federation: %w", err) + } + return rt, nil +} + +// oidcAccessToken returns an access token obtained through workload identity federation (OIDC). +func oidcAccessToken() (string, error) { + rt, err := oidcRoundTripper() + if err != nil { + return "", err + } + flow, ok := rt.(interface { + GetAccessToken() (string, error) + }) + if !ok { + return "", fmt.Errorf("workload identity federation does not support retrieving access tokens") + } + return flow.GetAccessToken() +} + func getEmailFromToken(token string) (string, error) { // We can safely use ParseUnverified because we are not authenticating the user at this point, // We are parsing the token just to get the service account e-mail @@ -185,6 +217,14 @@ func getEmailFromToken(token string) (string, error) { // For user token flows, it refreshes the token if necessary. // For service account flows, it returns the current access token. func GetValidAccessToken(p *print.Printer) (string, error) { + if accessToken := os.Getenv(envAccessTokenName); accessToken != "" { + return accessToken, nil + } + + if IsOIDCEnabled() { + return oidcAccessToken() + } + flow, err := GetAuthFlow() if err != nil { return "", fmt.Errorf("get authentication flow: %w", err) diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go index b3418e731..3c32791ed 100644 --- a/internal/pkg/auth/auth_test.go +++ b/internal/pkg/auth/auth_test.go @@ -16,10 +16,12 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/spf13/viper" "github.com/stackitcloud/stackit-sdk-go/core/clients" sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/zalando/go-keyring" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -400,3 +402,139 @@ func TestGetAccessToken_EnvVar(t *testing.T) { }) } } + +func TestUserSessionExpired(t *testing.T) { + const emptyExpiresAt = "" + + tests := []struct { + description string + envToken string + useOIDC string + expiresAt string + expected bool + isValid bool + }{ + { + description: "stored session not expired", + expiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), + isValid: true, + }, + { + description: "stored session expired", + expiresAt: strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10), + expected: true, + isValid: true, + }, + { + description: "stored session invalid", + expiresAt: "not-a-number", + }, + { + description: "no stored session", + }, + { + description: "empty stored session expiration", + expiresAt: emptyExpiresAt, + isValid: true, + }, + { + description: "access token from env ignores expired session", + envToken: "token", + expiresAt: strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10), + isValid: true, + }, + { + description: "oidc ignores invalid session", + useOIDC: "1", + expiresAt: "not-a-number", + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // avoid reading the real auth storage file + t.Setenv(envAccessTokenName, tt.envToken) + t.Setenv(EnvUseOIDC, tt.useOIDC) + if tt.expiresAt != "" { + expiresAt := tt.expiresAt + if expiresAt == emptyExpiresAt { + expiresAt = "" + } + if err := SetAuthField(SESSION_EXPIRES_AT_UNIX, expiresAt); err != nil { + t.Fatalf("Failed to set session expiration: %v", err) + } + } + + got, err := UserSessionExpired() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + if !tt.isValid { + t.Fatalf("expected error, got none") + } + if got != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, got) + } + }) + } +} + +func TestGetValidAccessToken_Env(t *testing.T) { + t.Run("access token from env", func(t *testing.T) { + keyring.MockInit() + t.Setenv(EnvUseOIDC, "") + t.Setenv(envAccessTokenName, "env-token") + + got, err := GetValidAccessToken(testparams.NewTestParams().Printer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "env-token" { + t.Errorf("expected %q, got %q", "env-token", got) + } + }) + + t.Run("oidc without service account email", func(t *testing.T) { + keyring.MockInit() + t.Setenv(envAccessTokenName, "") + t.Setenv(EnvUseOIDC, "1") + t.Setenv(EnvServiceAccountEmail, "") + + if _, err := GetValidAccessToken(testparams.NewTestParams().Printer); err == nil { + t.Fatalf("expected error, got none") + } + }) + + t.Run("oidc", func(t *testing.T) { + keyring.MockInit() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "wif-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer server.Close() + + viper.Set(config.TokenCustomEndpointKey, server.URL) + defer viper.Set(config.TokenCustomEndpointKey, "") + t.Setenv(envAccessTokenName, "") + t.Setenv(EnvUseOIDC, "1") + t.Setenv(EnvServiceAccountEmail, "sa@sa.stackit.cloud") + t.Setenv(EnvServiceAccountFederatedToken, "federated-token") + + got, err := GetValidAccessToken(testparams.NewTestParams().Printer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "wif-token" { + t.Errorf("expected %q, got %q", "wif-token", got) + } + }) +} diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 686a0f677..1ef932ddc 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -346,6 +346,7 @@ func GetProfileEmail(profile string) string { // GetAuthEmail returns the email of the authenticated account. // If the environment variable STACKIT_ACCESS_TOKEN is set, the email of this token will be returned. +// If OIDC is enabled, the service account email from the environment will be returned. func GetAuthEmail() (string, error) { // If STACKIT_ACCESS_TOKEN is set, get the mail from the token if accessToken := os.Getenv(envAccessTokenName); accessToken != "" { @@ -356,6 +357,12 @@ func GetAuthEmail() (string, error) { return email, nil } + if IsOIDCEnabled() { + if email := OIDCServiceAccountEmail(); email != "" { + return email, nil + } + } + profile, err := config.GetProfile() if err != nil { return "", fmt.Errorf("error getting profile: %w", err) diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index d7320917e..b82f45ad8 100644 --- a/internal/pkg/auth/storage_test.go +++ b/internal/pkg/auth/storage_test.go @@ -1216,3 +1216,18 @@ func TestAuthorizeDeauthorizeUserProfileAuth(t *testing.T) { }) } } + +func TestGetAuthEmail_OIDC(t *testing.T) { + keyring.MockInit() + t.Setenv(envAccessTokenName, "") + t.Setenv(EnvUseOIDC, "1") + t.Setenv(EnvServiceAccountEmail, "sa@sa.stackit.cloud") + + got, err := GetAuthEmail() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sa@sa.stackit.cloud" { + t.Errorf("expected %q, got %q", "sa@sa.stackit.cloud", got) + } +}