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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
## Release (2025-XX-YY)
- `core`:
- [v0.21.0](core/CHANGELOG.md#v0210)
- **Chore:** Use `jwt-bearer` grant to get a fresh token instead of `refresh_token`
- **Feature:** Support Workload Identity Federation flow
- `scf`: [v0.3.0](services/scf/CHANGELOG.md#v030)
- **Feature:** Add new model `IsolationSegment` and `IsolationSegmentsList`
- `iaas`:
Expand Down
5 changes: 5 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## v0.21.0
- **Chore:** Use `jwt-bearer` grant to get a fresh token instead of `refresh_token`
- **Feature:** Support Workload Identity Federation flow

## v0.20.1
- **Improvement:** Improve error message when passing a PEM encoded file to as service account key

Expand All @@ -9,6 +13,7 @@

## v0.18.0
- **New:** Added duration utils
- **Chore:** Use `jwt-bearer` grant to get a fresh token instead of `refresh_token`

## v0.17.3
- **Dependencies:** Bump `github.com/golang-jwt/jwt/v5` from `v5.2.2` to `v5.2.3`
Expand Down
2 changes: 1 addition & 1 deletion core/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.20.1
v0.21.0
53 changes: 47 additions & 6 deletions core/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {

if cfg.CustomAuth != nil {
return cfg.CustomAuth, nil
} else if useWorkloadIdentityFederation(cfg) {
wifRoundTripper, err := WorkloadIdentityFederationAuth(cfg)
if err != nil {
return nil, fmt.Errorf("configuring no auth client: %w", err)
}
return wifRoundTripper, nil
} else if cfg.NoAuth {
noAuthRoundTripper, err := NoAuth(cfg)
if err != nil {
Expand Down Expand Up @@ -84,14 +90,18 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {
cfg = &config.Configuration{}
}

// Key flow
rt, err = KeyAuth(cfg)
// WIF flow
rt, err = WorkloadIdentityFederationAuth(cfg)
if err != nil {
keyFlowErr := err
// Token flow
rt, err = TokenAuth(cfg)
// Key flow
rt, err = KeyAuth(cfg)
if err != nil {
return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %w", keyFlowErr.Error(), err)
keyFlowErr := err
// Token flow
rt, err = TokenAuth(cfg)
if err != nil {
return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %w", keyFlowErr.Error(), err)
}
}
}
return rt, nil
Expand Down Expand Up @@ -221,6 +231,29 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) {
return client, nil
}

// WorkloadIdentityFederationAuth configures the wif flow and returns an http.RoundTripper
// that can be used to make authenticated requests using an access token
func WorkloadIdentityFederationAuth(cfg *config.Configuration) (http.RoundTripper, error) {
wifConfig := clients.WorkloadIdentityFederationFlowConfig{
TokenUrl: cfg.TokenCustomUrl,
BackgroundTokenRefreshContext: cfg.BackgroundTokenRefreshContext,
ClientID: cfg.ServiceAccountEmail,
FederatedTokenFilePath: cfg.WorkloadIdentityFederationFederatedTokenPath,
TokenExpiration: cfg.WorkloadIdentityFederationTokenExpiration,
}

if cfg.HTTPClient != nil && cfg.HTTPClient.Transport != nil {
wifConfig.HTTPTransport = cfg.HTTPClient.Transport
}

client := &clients.WorkloadIdentityFederationFlow{}
if err := client.Init(&wifConfig); err != nil {
return nil, fmt.Errorf("error initializing client: %w", err)
}

return client, nil
}

// readCredentialsFile reads the credentials file from the specified path and returns Credentials
func readCredentialsFile(path string) (*Credentials, error) {
if path == "" {
Expand Down Expand Up @@ -361,3 +394,11 @@ func getServiceAccountKey(cfg *config.Configuration) error {
func getPrivateKey(cfg *config.Configuration) error {
return getKey(&cfg.PrivateKey, &cfg.PrivateKeyPath, "STACKIT_PRIVATE_KEY_PATH", "STACKIT_PRIVATE_KEY", privateKeyPathCredentialType, privateKeyCredentialType, cfg.CredentialsFilePath)
}

func useWorkloadIdentityFederation(cfg *config.Configuration) bool {
if cfg != nil && cfg.WorkloadIdentityFederation {
return true
}
val, exists := os.LookupEnv(clients.FederatedTokenFileEnv)
return exists && val != ""
}
101 changes: 87 additions & 14 deletions core/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"testing"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/stackitcloud/stackit-sdk-go/core/clients"
"github.com/stackitcloud/stackit-sdk-go/core/config"
Expand Down Expand Up @@ -121,6 +122,32 @@ func TestSetupAuth(t *testing.T) {
}
}()

// create a wif assertion file
wifAssertionFile, errs := os.CreateTemp("", "temp-*.txt")
if errs != nil {
t.Fatalf("Creating temporary file: %s", err)
}
defer func() {
_ = wifAssertionFile.Close()
err := os.Remove(wifAssertionFile.Name())
if err != nil {
t.Fatalf("Removing temporary file: %s", err)
}
}()

token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
Subject: "sub",
}).SignedString([]byte("test"))
if err != nil {
t.Fatalf("Removing temporary file: %s", err)
}

_, errs = wifAssertionFile.WriteString(string(token))
if errs != nil {
t.Fatalf("Writing wif assertion to temporary file: %s", err)
}

// create a credentials file with saKey and private key
credentialsKeyFile, errs := os.CreateTemp("", "temp-*.txt")
if errs != nil {
Expand All @@ -147,48 +174,48 @@ func TestSetupAuth(t *testing.T) {
desc string
config *config.Configuration
setToken bool
setWorkloadIdentity bool
setKeys bool
setKeyPaths bool
setCredentialsFilePathToken bool
setCredentialsFilePathKey bool
isValid bool
}{
{
desc: "wif_config",
config: nil,
setWorkloadIdentity: true,
},
{
desc: "token_config",
config: nil,
setToken: true,
setCredentialsFilePathToken: false,
isValid: true,
},
{
desc: "key_config",
config: nil,
setKeys: true,
setCredentialsFilePathToken: false,
isValid: true,
},
{
desc: "key_config_path",
config: nil,
setKeys: false,
setKeyPaths: true,
setCredentialsFilePathToken: false,
isValid: true,
},
{
desc: "key_config_credentials_path",
config: nil,
setKeys: false,
setKeyPaths: false,
setCredentialsFilePathKey: true,
isValid: true,
},
{
desc: "valid_path_to_file",
config: nil,
setToken: false,
setCredentialsFilePathToken: true,
isValid: true,
},
{
desc: "custom_config_token",
Expand All @@ -197,7 +224,6 @@ func TestSetupAuth(t *testing.T) {
},
setToken: false,
setCredentialsFilePathToken: false,
isValid: true,
},
{
desc: "custom_config_path",
Expand All @@ -206,7 +232,6 @@ func TestSetupAuth(t *testing.T) {
},
setToken: false,
setCredentialsFilePathToken: false,
isValid: true,
},
} {
t.Run(test.desc, func(t *testing.T) {
Expand Down Expand Up @@ -241,19 +266,21 @@ func TestSetupAuth(t *testing.T) {
t.Setenv("STACKIT_CREDENTIALS_PATH", "")
}

if test.setWorkloadIdentity {
t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", wifAssertionFile.Name())
} else {
t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", "")
}

t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email")

authRoundTripper, err := SetupAuth(test.config)

if err != nil && test.isValid {
if err != nil {
t.Fatalf("Test returned error on valid test case: %v", err)
}

if err == nil && !test.isValid {
t.Fatalf("Test didn't return error on invalid test case")
}

if test.isValid && authRoundTripper == nil {
if authRoundTripper == nil {
t.Fatalf("Roundtripper returned is nil for valid test case")
}
})
Expand Down Expand Up @@ -381,6 +408,32 @@ func TestDefaultAuth(t *testing.T) {
t.Fatalf("Writing private key to temporary file: %s", err)
}

// create a wif assertion file
wifAssertionFile, errs := os.CreateTemp("", "temp-*.txt")
if errs != nil {
t.Fatalf("Creating temporary file: %s", err)
}
defer func() {
_ = wifAssertionFile.Close()
err := os.Remove(wifAssertionFile.Name())
if err != nil {
t.Fatalf("Removing temporary file: %s", err)
}
}()

token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
Subject: "sub",
}).SignedString([]byte("test"))
if err != nil {
t.Fatalf("Removing temporary file: %s", err)
}

_, errs = wifAssertionFile.WriteString(string(token))
if errs != nil {
t.Fatalf("Writing wif assertion to temporary file: %s", err)
}

// create a credentials file with saKey and private key
credentialsKeyFile, errs := os.CreateTemp("", "temp-*.txt")
if errs != nil {
Expand Down Expand Up @@ -409,6 +462,7 @@ func TestDefaultAuth(t *testing.T) {
setKeyPaths bool
setKeys bool
setCredentialsFilePathKey bool
setWorkloadIdentity bool
isValid bool
expectedFlow string
}{
Expand All @@ -418,6 +472,14 @@ func TestDefaultAuth(t *testing.T) {
isValid: true,
expectedFlow: "token",
},
{
desc: "wif_precedes_key_precedes_token",
setToken: true,
setKeyPaths: true,
setWorkloadIdentity: true,
isValid: true,
expectedFlow: "wif",
},
{
desc: "key_precedes_token",
setToken: true,
Expand Down Expand Up @@ -475,6 +537,13 @@ func TestDefaultAuth(t *testing.T) {
} else {
t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", "")
}

if test.setWorkloadIdentity {
t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", wifAssertionFile.Name())
} else {
t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", "")
}

t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email")

// Get the default authentication client and ensure that it's not nil
Expand All @@ -501,6 +570,10 @@ func TestDefaultAuth(t *testing.T) {
if _, ok := authClient.(*clients.KeyFlow); !ok {
t.Fatalf("Expected key flow, got %s", reflect.TypeOf(authClient))
}
case "wif":
if _, ok := authClient.(*clients.WorkloadIdentityFederationFlow); !ok {
t.Fatalf("Expected key flow, got %s", reflect.TypeOf(authClient))
}
}
}
})
Expand Down
Loading