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
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
- `v1api`:
- **New:** Add package which can be used for communication with the STACKIT automation v1 API
- **Feature:** Add waiter method for the API
- `core`: [v0.27.1](core/CHANGELOG.md#v0271)
- **Bugfix:** `WaitWithContext` no longer returns `(nil, nil)` after a single retryable `502`/`504` error
- `core`:
- [v0.28.0](core/CHANGELOG.md#v0280)
- **Feature:** Support metadata flow, authenticating as the service account attached to the server
- [v0.27.1](core/CHANGELOG.md#v0271)
- **Bugfix:** `WaitWithContext` no longer returns `(nil, nil)` after a single retryable `502`/`504` error
- `cost`:
- [v0.5.3](services/cost/CHANGELOG.md#v053)
- **Dependencies:** Bump STACKIT SDK core module from `v0.27.0` to `v0.27.1`
Expand Down
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,26 @@ To authenticate with the SDK, you need a [service account](https://docs.stackit.

### Authentication Methods

The SDK supports three authentication methods:
The SDK supports four authentication methods:

1. **Workload Identity Federation Flow**

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

2. **Key Flow**
2. **Metadata Flow**

- Uses the service account attached to the server the code runs on
- Provides short-lived tokens from the server's metadata service without secrets
- Is only used when configured explicitly

3. **Key Flow**

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

3. **Token Flow** (Deprecated)
4. **Token Flow** (Deprecated)
- Uses long-lived service account tokens
- Simpler but less secure

Expand Down Expand Up @@ -159,6 +165,30 @@ STACKIT_FEDERATED_TOKEN_FILE=/path/to/your/federated/token
STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa-stackit.cloud
```

### Using the Metadata Flow

1. Attach the service account to the server, on creation or later (see [Use Service Accounts via the IaaS-API](https://docs.stackit.cloud/products/iaas-api/how-tos/use-service-accounts-via-the-iaas-api/)):

```bash
stackit server create --service-account-emails my-sa@sa.stackit.cloud --name my-server --machine-type g1.1
```

2. Configure authentication on that server using any of these methods:

**A. Code Configuration**

```go
// Using metadata flow
config.WithMetadataAuth()
// For the attached service account
config.WithServiceAccountEmail("my-sa@sa.stackit.cloud")
```
**B. Environment Variables**
```bash
# For the attached service account
STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa.stackit.cloud
```

### Using the Key Flow

1. Create a service account key in the STACKIT Portal:
Expand Down
3 changes: 3 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v0.28.0
- **Feature:** Support metadata flow, authenticating as the service account attached to the server

## v0.27.1
- **Bugfix:** `WaitWithContext` no longer returns `(nil, nil)` after a single retryable `502`/`504` error. `WaiterHelper.Wait()` now correctly returns `waitFinished = false` on generic fetch errors

Expand Down
2 changes: 1 addition & 1 deletion core/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.27.1
v0.28.0
26 changes: 26 additions & 0 deletions core/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {
return nil, fmt.Errorf("configuring workload identity federation client: %w", err)
}
return wifRoundTripper, nil
} else if cfg.MetadataAuth {
metadataRoundTripper, err := MetadataAuth(cfg)
if err != nil {
return nil, fmt.Errorf("configuring metadata client: %w", err)
}
return metadataRoundTripper, nil
} else if cfg.ServiceAccountKey != "" || cfg.ServiceAccountKeyPath != "" {
keyRoundTripper, err := KeyAuth(cfg)
if err != nil {
Expand Down Expand Up @@ -254,6 +260,26 @@ func WorkloadIdentityFederationAuth(cfg *config.Configuration) (http.RoundTrippe
return client, nil
}

// MetadataAuth configures the metadata flow and returns an http.RoundTripper
// that can be used to make authenticated requests as the service account attached to the server
func MetadataAuth(cfg *config.Configuration) (http.RoundTripper, error) {
metadataConfig := clients.MetadataFlowConfig{
ServiceAccountEmail: cfg.ServiceAccountEmail,
BackgroundTokenRefreshContext: cfg.BackgroundTokenRefreshContext,
}

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

client := &clients.MetadataFlow{}
if err := client.Init(&metadataConfig); 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
21 changes: 21 additions & 0 deletions core/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,27 @@ func TestSetupAuthWorkloadIdentityErrorMessage(t *testing.T) {
}
}

func TestSetupAuthMetadata(t *testing.T) {
setTemporaryHome(t)
t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "")

rt, err := SetupAuth(&config.Configuration{MetadataAuth: true, ServiceAccountEmail: "test@sa.stackit.cloud"})
if err != nil {
t.Fatalf("setting up metadata auth: %s", err)
}
if _, ok := rt.(*clients.MetadataFlow); !ok {
t.Fatalf("expected metadata flow, got %T", rt)
}

_, err = SetupAuth(&config.Configuration{MetadataAuth: true})
if err == nil {
t.Fatalf("error expected")
}
if !strings.Contains(err.Error(), "configuring metadata client") {
t.Fatalf("expected metadata error, got %s", err)
}
}

func TestNoAuth(t *testing.T) {
for _, test := range []struct {
desc string
Expand Down
215 changes: 215 additions & 0 deletions core/clients/metadata_flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package clients

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
)

const (
defaultMetadataUrl = "http://169.254.169.254"
// The metadata service issues tokens valid for an hour
metadataTokenExpirationLeeway = 5 * time.Minute
)

var _ AuthFlow = &MetadataFlow{}

// MetadataFlow handles auth with the service account attached to the server,
// using the tokens its metadata service issues:
// https://docs.stackit.cloud/products/iaas-api/how-tos/use-service-accounts-via-the-iaas-api/
type MetadataFlow struct {
rt http.RoundTripper
metadataClient *http.Client
config *MetadataFlowConfig

tokenMutex sync.RWMutex
token *MetadataTokenResponseBody

// If the current access token would expire in less than TokenExpirationLeeway,
// the client will refresh it early to prevent clock skew or other timing issues.
tokenExpirationLeeway time.Duration
}

// MetadataFlowConfig is the flow config
type MetadataFlowConfig struct {
ServiceAccountEmail string
MetadataUrl string
BackgroundTokenRefreshContext context.Context // Functionality is enabled if this isn't nil
HTTPTransport http.RoundTripper
MetadataHTTPClient *http.Client
}

// MetadataTokenResponseBody is the metadata service response
// when requesting a token
type MetadataTokenResponseBody struct {
Token string `json:"token"`
ValidUntil time.Time `json:"validUntil"`
}

// GetConfig returns the flow configuration
func (c *MetadataFlow) GetConfig() MetadataFlowConfig {
if c.config == nil {
return MetadataFlowConfig{}
}
return *c.config
}

// GetAccessToken implements AuthFlow.
func (c *MetadataFlow) GetAccessToken() (string, error) {
if c.rt == nil {
return "", fmt.Errorf("nil http round tripper, please run Init()")
}

c.tokenMutex.RLock()
token := c.token
c.tokenMutex.RUnlock()

if token != nil && time.Now().Add(c.tokenExpirationLeeway).Before(token.ValidUntil) {
return token.Token, nil
}
if err := c.createAccessToken(); err != nil {
return "", fmt.Errorf("get new access token: %w", err)
}

c.tokenMutex.RLock()
defer c.tokenMutex.RUnlock()
return c.token.Token, nil
}

func (c *MetadataFlow) refreshAccessToken() error {
return c.createAccessToken()
}

// RoundTrip implements the http.RoundTripper interface.
// It gets a token, adds it to the request's authorization header, and performs the request.
func (c *MetadataFlow) RoundTrip(req *http.Request) (*http.Response, error) {
if c.rt == nil {
return nil, fmt.Errorf("please run Init()")
}

accessToken, err := c.GetAccessToken()
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
return c.rt.RoundTrip(req)
}

// getBackgroundTokenRefreshContext implements AuthFlow.
func (c *MetadataFlow) getBackgroundTokenRefreshContext() context.Context {
return c.config.BackgroundTokenRefreshContext
}

func (c *MetadataFlow) Init(cfg *MetadataFlowConfig) error {
// No concurrency at this point, so no mutex check needed
c.token = nil
c.config = cfg

if c.config.ServiceAccountEmail == "" {
c.config.ServiceAccountEmail = utils.GetEnvOrDefault(clientIDEnv, "")
}

if c.config.MetadataUrl == "" {
c.config.MetadataUrl = defaultMetadataUrl
}

c.tokenExpirationLeeway = metadataTokenExpirationLeeway

if c.rt = cfg.HTTPTransport; c.rt == nil {
c.rt = http.DefaultTransport
}

if c.metadataClient = cfg.MetadataHTTPClient; c.metadataClient == nil {
c.metadataClient = &http.Client{
// The metadata service is link-local, so it is never reached through a proxy
Transport: &http.Transport{},
Timeout: DefaultClientTimeout,
}
}

err := c.validate()
if err != nil {
return err
}

if c.config.BackgroundTokenRefreshContext != nil {
go continuousRefreshToken(c)
}
return nil
}

func (c *MetadataFlow) validate() error {
if c.config.ServiceAccountEmail == "" {
return fmt.Errorf("service account email cannot be empty")
}
if _, err := url.ParseRequestURI(c.config.MetadataUrl); err != nil {
return fmt.Errorf("parse metadata URL: %w", err)
}
if c.tokenExpirationLeeway < 0 {
return fmt.Errorf("token expiration leeway cannot be negative")
}

return nil
}

func (c *MetadataFlow) createAccessToken() (err error) {
res, err := c.requestToken()
if err != nil {
return err
}
defer func() {
tempErr := res.Body.Close()
if tempErr != nil && err == nil {
err = fmt.Errorf("close request access token response: %w", tempErr)
}
}()

body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
apiErr := &oapierror.GenericOpenAPIError{
StatusCode: res.StatusCode,
Body: body,
}
if res.StatusCode == http.StatusNotFound {
return fmt.Errorf("service account %s is not attached to this server: %w", c.config.ServiceAccountEmail, apiErr)
}
return apiErr
}

token := &MetadataTokenResponseBody{}
if err := json.Unmarshal(body, token); err != nil {
return fmt.Errorf("unmarshal token response: %w", err)
}
if token.Token == "" || token.ValidUntil.IsZero() {
return fmt.Errorf("token response lacks token or validUntil")
}

c.tokenMutex.Lock()
c.token = token
c.tokenMutex.Unlock()
return nil
}

func (c *MetadataFlow) requestToken() (*http.Response, error) {
tokenUrl := strings.TrimSuffix(c.config.MetadataUrl, "/") +
"/stackit/v1/service-accounts/" + url.PathEscape(c.config.ServiceAccountEmail) + "/token"
req, err := http.NewRequest(http.MethodGet, tokenUrl, http.NoBody)
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/json")

return c.metadataClient.Do(req)
}
Loading