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
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,20 @@ To authenticate with the SDK, you need a [service account](https://docs.stackit.

The SDK supports two authentication methods:

1. **Key Flow** (Recommended)
1. **Workload Identity Federation Flow** (Recommended)

- Uses OIDC trusted tokens
- Provides best security through short-lived tokens without secrets

> NOTE: This flow isn't publicly available yet. It'll be public during Q1 2026

2. **Key Flow** (Recommended)

- Uses RSA key-pair based authentication
- Provides better security through short-lived tokens
- Supports both STACKIT-generated and custom key pairs

2. **Token Flow**
3. **Token Flow**
- Uses long-lived service account tokens
- Simpler but less secure

Expand All @@ -120,10 +127,42 @@ The SDK supports two authentication methods:
The SDK searches for credentials in the following order:

1. Explicit configuration in code
2. Environment variables (KEY_PATH for KEY)
2. Environment variables
3. Credentials file (`$HOME/.stackit/credentials.json`)

For each authentication method, the key flow is attempted first, followed by the token flow.
For each authentication method, the try order is:
1. Workload Identity Federation Flow
2. Key Flow
3. Token Flow

### Using the Workload Identity Fedearion Flow

1. Create a service account trusted relation in the STACKIT Portal:

- Navigate to `Service Accounts` → Select account → `Federated Identity Providers` → Add a Federated Identity Provider
- Configure the trusted issuer and the required assertions to trust in. (Link to official docs here after GA)

2. Configure authentication using any of these methods:

**A. Code Configuration**

```go
// Using wokload identity federation flow
config.WithWorkloadIdentityFederationAuth()
// With the custom path for the external OIDC token
config.WithWorkloadIdentityFederationTokenPath("/path/to/your/federated/token")
// For the service account
config.WithServiceAccountEmail("my-sa@sa-stackit.cloud")
```

**B. Environment Variables**

```bash
# With the custom path for the external OIDC token
STACKIT_FEDERATED_TOKEN_FILE=/path/to/your/federated/token
# For the service account
STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa-stackit.cloud
```

### Using the Key Flow

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
45 changes: 39 additions & 6 deletions core/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {
return nil, fmt.Errorf("configuring no auth client: %w", err)
}
return noAuthRoundTripper, nil
} else if cfg.WorkloadIdentityFederation {
wifRoundTripper, err := WorkloadIdentityFederationAuth(cfg)
if err != nil {
return nil, fmt.Errorf("configuring no auth client: %w", err)
}
return wifRoundTripper, nil
} else if cfg.ServiceAccountKey != "" || cfg.ServiceAccountKeyPath != "" {
keyRoundTripper, err := KeyAuth(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
88 changes: 87 additions & 1 deletion 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,12 +174,19 @@ 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,
isValid: true,
},
{
desc: "token_config",
config: nil,
Expand Down Expand Up @@ -241,6 +275,12 @@ 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)
Expand All @@ -253,7 +293,7 @@ func TestSetupAuth(t *testing.T) {
t.Fatalf("Test didn't return error on invalid test case")
}

if test.isValid && authRoundTripper == nil {
if authRoundTripper == nil && test.isValid {
t.Fatalf("Roundtripper returned is nil for valid test case")
}
})
Expand Down Expand Up @@ -381,6 +421,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 +475,7 @@ func TestDefaultAuth(t *testing.T) {
setKeyPaths bool
setKeys bool
setCredentialsFilePathKey bool
setWorkloadIdentity bool
isValid bool
expectedFlow string
}{
Expand All @@ -418,6 +485,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 +550,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 +583,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