diff --git a/CHANGELOG.md b/CHANGELOG.md index 1253beb99d..bc7790e1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index c2e7744375..89a60c28de 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index f872c34cee..a504974e2d 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -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 diff --git a/core/VERSION b/core/VERSION index 04e94578bd..31950dacac 100644 --- a/core/VERSION +++ b/core/VERSION @@ -1 +1 @@ -v0.27.1 +v0.28.0 diff --git a/core/auth/auth.go b/core/auth/auth.go index 273bcb0188..64a2b7c824 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -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 { @@ -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 == "" { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index f67ca55cef..0e24b2086e 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -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 diff --git a/core/clients/metadata_flow.go b/core/clients/metadata_flow.go new file mode 100644 index 0000000000..166436c544 --- /dev/null +++ b/core/clients/metadata_flow.go @@ -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) +} diff --git a/core/clients/metadata_flow_test.go b/core/clients/metadata_flow_test.go new file mode 100644 index 0000000000..a4e5da16be --- /dev/null +++ b/core/clients/metadata_flow_test.go @@ -0,0 +1,196 @@ +package clients + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" +) + +func TestMetadataFlowInit(t *testing.T) { + tests := []struct { + name string + serviceAccountEmail string + emailAsEnv bool + metadataUrl string + wantErr bool + }{ + { + name: "ok setting all", + serviceAccountEmail: "test@sa.stackit.cloud", + metadataUrl: "http://localhost:8080", + }, + { + name: "ok using defaults", + serviceAccountEmail: "test@sa.stackit.cloud", + emailAsEnv: true, + }, + { + name: "missing service account email", + wantErr: true, + }, + { + name: "invalid metadata url", + serviceAccountEmail: "test@sa.stackit.cloud", + metadataUrl: "not a url", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flowConfig := &MetadataFlowConfig{MetadataUrl: tt.metadataUrl} + if tt.emailAsEnv { + t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", tt.serviceAccountEmail) + } else { + t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "") + flowConfig.ServiceAccountEmail = tt.serviceAccountEmail + } + + flow := &MetadataFlow{} + err := flow.Init(flowConfig) + if (err != nil) != tt.wantErr { + t.Fatalf("Init() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if flow.GetConfig().ServiceAccountEmail != tt.serviceAccountEmail { + t.Fatalf("service account email = %s, want %s", flow.GetConfig().ServiceAccountEmail, tt.serviceAccountEmail) + } + if tt.metadataUrl == "" && flow.GetConfig().MetadataUrl != defaultMetadataUrl { + t.Fatalf("metadata url = %s, want %s", flow.GetConfig().MetadataUrl, defaultMetadataUrl) + } + }) + } +} + +func TestMetadataFlowRoundTrip(t *testing.T) { + serviceAccountEmail := "test@sa.stackit.cloud" + tests := []struct { + name string + metadataStatus int + validFor time.Duration + omitValidUntil bool + wantMetadataCalls int32 + wantErr bool + wantErrContains string + wantOpenAPIErrCode int + }{ + { + name: "token is reused while valid", + metadataStatus: http.StatusOK, + validFor: time.Hour, + wantMetadataCalls: 1, + }, + { + name: "token is renewed when about to expire", + metadataStatus: http.StatusOK, + validFor: 4 * time.Minute, + wantMetadataCalls: 2, + }, + { + name: "response without validUntil", + metadataStatus: http.StatusOK, + validFor: time.Hour, + omitValidUntil: true, + wantMetadataCalls: 1, + wantErr: true, + }, + { + name: "service account not attached", + metadataStatus: http.StatusNotFound, + wantMetadataCalls: 1, + wantErr: true, + wantErrContains: "is not attached to this server", + }, + { + name: "metadata service error", + metadataStatus: http.StatusInternalServerError, + wantMetadataCalls: 1, + wantErr: true, + wantOpenAPIErrCode: http.StatusInternalServerError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var metadataCalls atomic.Int32 + metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := metadataCalls.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/stackit/v1/service-accounts/"+serviceAccountEmail+"/token" { + t.Errorf("unexpected metadata request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.metadataStatus) + if tt.metadataStatus != http.StatusOK { + _, _ = w.Write([]byte(`{"code":"error"}`)) + return + } + response := map[string]string{"token": fmt.Sprintf("token-%d", n)} + if !tt.omitValidUntil { + response["validUntil"] = time.Now().Add(tt.validFor).UTC().Format(time.RFC3339Nano) + } + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Errorf("writing response: %s", err) + } + })) + t.Cleanup(metadataServer.Close) + + var wantBearer atomic.Int32 + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := fmt.Sprintf("Bearer token-%d", wantBearer.Load()) + if got := r.Header.Get("Authorization"); got != want { + t.Errorf("authorization header = %q, want %q", got, want) + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(apiServer.Close) + + flow := &MetadataFlow{} + err := flow.Init(&MetadataFlowConfig{ + ServiceAccountEmail: serviceAccountEmail, + MetadataUrl: metadataServer.URL, + }) + if err != nil { + t.Fatalf("Init() error = %v", err) + } + client := &http.Client{Transport: flow} + + for i := 1; i <= 2; i++ { + wantBearer.Store(min(int32(i), tt.wantMetadataCalls)) + req, err := http.NewRequest(http.MethodGet, apiServer.URL, http.NoBody) + if err != nil { + t.Fatalf("creating request: %s", err) + } + res, err := client.Do(req) + if err == nil { + _ = res.Body.Close() + } + if (err != nil) != tt.wantErr { + t.Fatalf("request error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr { + continue + } + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("error = %v, want it to contain %q", err, tt.wantErrContains) + } + oapiErr := &oapierror.GenericOpenAPIError{} + if tt.wantOpenAPIErrCode != 0 && (!errors.As(err, &oapiErr) || oapiErr.StatusCode != tt.wantOpenAPIErrCode) { + t.Fatalf("error = %v, want an API error with status %d", err, tt.wantOpenAPIErrCode) + } + break + } + + if got := metadataCalls.Load(); got != tt.wantMetadataCalls { + t.Fatalf("metadata calls = %d, want %d", got, tt.wantMetadataCalls) + } + }) + } +} diff --git a/core/config/config.go b/core/config/config.go index d31cfae75a..9fb851311e 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -83,6 +83,7 @@ type Configuration struct { Debug bool `json:"debug,omitempty"` NoAuth bool `json:"noAuth,omitempty"` WorkloadIdentityFederation bool `json:"workloadIdentityFederation,omitempty"` + MetadataAuth bool `json:"metadataAuth,omitempty"` ServiceAccountFederatedTokenExpiration string `json:"serviceAccountFederatedTokenExpiration,omitempty"` ServiceAccountFederatedTokenFunc oidcadapters.OIDCTokenFunc `json:"serviceAccountFederatedTokenFunc,omitempty"` ServiceAccountEmail string `json:"serviceAccountEmail,omitempty"` @@ -281,6 +282,15 @@ func WithWorkloadIdentityFederationTokenExpiration(expiration string) Configurat } } +// WithMetadataAuth returns a ConfigurationOption that sets the metadata flow to be used for authentication in API calls, +// authenticating as the service account attached to the server with tokens from its metadata service +func WithMetadataAuth() ConfigurationOption { + return func(config *Configuration) error { + config.MetadataAuth = true + return nil + } +} + // Deprecated: retry options were removed to reduce complexity of the client. If this functionality is needed, you can provide your own custom HTTP client. This option has no effect, and will be removed in a later update func WithMaxRetries(_ int) ConfigurationOption { return func(_ *Configuration) error { diff --git a/examples/authentication/authentication.go b/examples/authentication/authentication.go index 490826eade..0a737db6fa 100644 --- a/examples/authentication/authentication.go +++ b/examples/authentication/authentication.go @@ -41,6 +41,17 @@ func main() { os.Exit(1) } + // Create a new API client, that will authenticate as the service account attached to the server + // it runs on, using tokens from the server's metadata service + _, err = dns.NewAPIClient( + config.WithMetadataAuth(), + config.WithServiceAccountEmail("my-sa@sa.stackit.cloud"), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "[DNS API] Creating API client: %v\n", err) + os.Exit(1) + } + // Create a new API client, that will authenticate using the key flow // If you created a service account key and provided your own RSA key pair, // you need to add the path to a PEM encoded file including the private key