Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions internal/cmd/organization/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 61 additions & 21 deletions internal/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
138 changes: 138 additions & 0 deletions internal/pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -400,3 +402,139 @@ func TestGetAccessToken_EnvVar(t *testing.T) {
})
}
}

func TestUserSessionExpired(t *testing.T) {
const emptyExpiresAt = "<empty>"

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)
}
})
}
7 changes: 7 additions & 0 deletions internal/pkg/auth/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions internal/pkg/auth/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading