diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1ad4f966..79f8cb144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Release (2026-MM-DD) +- `core`: [v0.28.0](core/CHANGELOG.md#v0280) + - **Feature:** Added the `credentials` package with modular token providers, and `config.WithTokenProvider` to make `auth.DefaultAuth` authenticate through them. - `alb`: - [v0.19.0](services/alb/CHANGELOG.md#v0190) - **Breaking change**: Removal of deprecated compatibility layer previously found in the root of the SDK module diff --git a/README.md b/README.md index c2e7744375..029b048ea6 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,20 @@ The SDK supports three authentication methods: - Uses long-lived service account tokens - Simpler but less secure +### Using a token provider + +The [`core/credentials`](core/credentials/README.md) package offers modular token providers (workload identity federation, service account key, static token, instance metadata and the STACKIT CLI session), a way to combine them, and a default chain. To use one with any service client, pass it with `config.WithTokenProvider`: + +```go +tokenProvider, err := credentials.NewDefaultProvider(&credentials.DefaultProviderConfig{}) +if err != nil { /* handle */ } +client, err := dns.NewAPIClient(config.WithTokenProvider(tokenProvider)) +``` + +When a token provider is set, it is used instead of the flows described below, which remain unchanged. Options that explicitly choose another way to authenticate (`config.WithToken`, `config.WithServiceAccountKey`, ...) still take precedence over it. + +See the [package documentation](core/credentials/README.md) for the providers, the order of the default chain, how to get the raw access token and the configuration it reads. + ### Configuration Priority The SDK searches for credentials in the following order: diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index f872c34cee..8b57681352 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,3 +1,6 @@ +## v0.28.0 +- **Feature:** Added the `credentials` package with modular token providers, and `config.WithTokenProvider` to make `auth.DefaultAuth` authenticate through them. + ## 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..ed604d6c53 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -83,6 +83,7 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { // $HOME/.stackit/credentials.json as a fallback. If the key are found and are valid, the KeyAuth flow is used. // If the key flow cannot be used, it will try to find a token in the STACKIT_SERVICE_ACCOUNT_TOKEN. If not present, it will // search in the credentials file. If the token is found, the TokenAuth flow is used. +// If cfg.TokenProvider is set (see config.WithTokenProvider), it is used and the legacy flows are skipped. // DefaultAuth returns an http.RoundTripper that can be used to make authenticated requests. // In case the token is not found, DefaultAuth fails. func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { @@ -90,6 +91,15 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { cfg = &config.Configuration{} } + // Token provider from the credentials package takes precedence over the legacy flows + if cfg.TokenProvider != nil { + var base http.RoundTripper + if cfg.HTTPClient != nil { + base = cfg.HTTPClient.Transport + } + return newTokenProviderRoundTripper(cfg.TokenProvider, base), nil + } + // WIF flow rt, err = WorkloadIdentityFederationAuth(cfg) if err != nil { diff --git a/core/auth/default_auth_token_provider_test.go b/core/auth/default_auth_token_provider_test.go new file mode 100644 index 0000000000..312bc6f058 --- /dev/null +++ b/core/auth/default_auth_token_provider_test.go @@ -0,0 +1,65 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/credentials" +) + +type staticTP struct{ tok credentials.Token } + +func (s *staticTP) Token(context.Context, credentials.TokenRequestOptions) (credentials.Token, error) { + return s.tok, nil +} + +func TestDefaultAuthWithTokenProvider(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer abc" { + t.Errorf("unexpected Authorization header %q", got) + } + })) + defer srv.Close() + + cfg := &config.Configuration{} + if err := config.WithTokenProvider(&staticTP{credentials.Token{AccessToken: "abc"}})(cfg); err != nil { + t.Fatal(err) + } + rt, err := DefaultAuth(cfg) + if err != nil { + t.Fatalf("DefaultAuth: %v", err) + } + resp, err := (&http.Client{Transport: rt}).Get(srv.URL) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() +} + +// TestSetupAuthExplicitOptionsWinOverTokenProvider pins that an option which explicitly +// chooses how to authenticate is not overridden by a token provider. +func TestSetupAuthExplicitOptionsWinOverTokenProvider(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer explicit-token" { + t.Errorf("expected the explicit token to be used, got Authorization %q", got) + } + })) + defer srv.Close() + + cfg := &config.Configuration{Token: "explicit-token"} + if err := config.WithTokenProvider(&staticTP{credentials.Token{AccessToken: "from-provider"}})(cfg); err != nil { + t.Fatal(err) + } + rt, err := SetupAuth(cfg) + if err != nil { + t.Fatalf("SetupAuth: %v", err) + } + resp, err := (&http.Client{Transport: rt}).Get(srv.URL) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() +} diff --git a/core/auth/token_provider_roundtripper.go b/core/auth/token_provider_roundtripper.go new file mode 100644 index 0000000000..a6afb36cac --- /dev/null +++ b/core/auth/token_provider_roundtripper.go @@ -0,0 +1,54 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/stackitcloud/stackit-sdk-go/core/credentials" +) + +const authorizationSchemeBearer = "Bearer" + +type tokenProviderRoundTripper struct { + provider credentials.TokenProvider + rt http.RoundTripper +} + +func newTokenProviderRoundTripper(provider credentials.TokenProvider, rt http.RoundTripper) http.RoundTripper { + if rt == nil { + rt = http.DefaultTransport + } + return &tokenProviderRoundTripper{ + provider: provider, + rt: rt, + } +} + +func (r *tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + if r.provider == nil { + return nil, fmt.Errorf("token provider cannot be nil") + } + + token, err := r.provider.Token(req.Context(), credentials.TokenRequestOptions{}) + if err != nil { + return nil, fmt.Errorf("get access token from provider: %w", err) + } + if token.AccessToken == "" { + return nil, fmt.Errorf("token provider returned empty access token") + } + + // Use the scheme the token was issued with rather than assuming Bearer, so that a + // provider issuing e.g. an RFC 9449 (DPoP) token is presented correctly. A custom + // TokenProvider may leave it unset, so fall back to Bearer. + scheme := token.TokenType + if scheme == "" { + scheme = authorizationSchemeBearer + } + + requestCopy := req.Clone(req.Context()) + requestCopy.Header.Set("Authorization", fmt.Sprintf("%s %s", scheme, token.AccessToken)) + return r.rt.RoundTrip(requestCopy) +} diff --git a/core/auth/token_provider_roundtripper_test.go b/core/auth/token_provider_roundtripper_test.go new file mode 100644 index 0000000000..95eada05d0 --- /dev/null +++ b/core/auth/token_provider_roundtripper_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/credentials" +) + +type tokenProviderStub struct { + token credentials.Token + err error +} + +func (s *tokenProviderStub) Token(context.Context, credentials.TokenRequestOptions) (credentials.Token, error) { + if s.err != nil { + return credentials.Token{}, s.err + } + return s.token, nil +} + +type roundTripperStub func(*http.Request) (*http.Response, error) + +func (f roundTripperStub) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestTokenProviderRoundTripper(t *testing.T) { + rt := newTokenProviderRoundTripper(&tokenProviderStub{ + token: credentials.Token{AccessToken: "token-value", RefreshOn: time.Now().Add(time.Hour)}, + }, roundTripperStub(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer token-value" { + t.Fatalf("unexpected authorization header: %s", req.Header.Get("Authorization")) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header)}, nil + })) + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + if err != nil { + t.Fatalf("create request: %v", err) + } + + res, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("round trip failed: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("unexpected status code: %d", res.StatusCode) + } + if err := res.Body.Close(); err != nil { + t.Fatalf("close response body: %v", err) + } +} + +func TestTokenProviderRoundTripperProviderError(t *testing.T) { + rt := newTokenProviderRoundTripper(&tokenProviderStub{err: fmt.Errorf("failed")}, roundTripperStub(func(_ *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + if err != nil { + t.Fatalf("create request: %v", err) + } + + res, err := rt.RoundTrip(req) //nolint:bodyclose // Not executed due to error in provider + if err == nil { + t.Fatalf("expected error") + } + if res != nil { + res.Body.Close() // nolint:errcheck // Response may be nil, only closing if not + } + if !strings.Contains(err.Error(), "get access token from provider") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestTokenProviderRoundTripperUsesTokenType checks that the Authorization scheme comes +// from the token rather than being assumed to be Bearer, which is what makes tokens such +// as RFC 9449 (DPoP) presentable. +func TestTokenProviderRoundTripperUsesTokenType(t *testing.T) { + rt := newTokenProviderRoundTripper(&tokenProviderStub{ + token: credentials.Token{AccessToken: "token-value", TokenType: "DPoP", RefreshOn: time.Now().Add(time.Hour)}, + }, roundTripperStub(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get("Authorization"); got != "DPoP token-value" { + t.Fatalf("unexpected authorization header: %s", got) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header)}, nil + })) + + req, err := http.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + if err != nil { + t.Fatalf("create request: %v", err) + } + res, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("round trip: %v", err) + } + _ = res.Body.Close() +} diff --git a/core/config/config.go b/core/config/config.go index d31cfae75a..d6791ac16a 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -11,6 +11,7 @@ import ( "time" "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/credentials" "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" ) @@ -95,6 +96,7 @@ type Configuration struct { TokenCustomUrl string `json:"tokenCustomUrl,omitempty"` Region string `json:"region,omitempty"` CustomAuth http.RoundTripper + TokenProvider credentials.TokenProvider // used by auth.DefaultAuth instead of the legacy flows when set Servers ServerConfigurations OperationServers map[string]ServerConfigurations HTTPClient *http.Client @@ -388,6 +390,15 @@ func WithCaptureHTTPResponse(parent context.Context, resp **http.Response) conte return context.WithValue(parent, ContextHTTPResponse, resp) } +// WithTokenProvider returns a ConfigurationOption that sets a TokenProvider from the credentials package. +// When set, auth.DefaultAuth authenticates requests with it instead of the legacy flows. +func WithTokenProvider(tp credentials.TokenProvider) ConfigurationOption { + return func(config *Configuration) error { + config.TokenProvider = tp + return nil + } +} + // ServerVariable stores the information about a server variable type ServerVariable struct { Description string diff --git a/core/credentials/README.md b/core/credentials/README.md new file mode 100644 index 0000000000..e06d1677e9 --- /dev/null +++ b/core/credentials/README.md @@ -0,0 +1,225 @@ +# credentials + +Package `credentials` gets access tokens for the STACKIT APIs. Everything builds on one small contract: + +```go +type TokenProvider interface { + Token(ctx context.Context, options TokenRequestOptions) (Token, error) +} +``` + +Each authentication flow is a provider that implements it, and providers can be combined. A provider caches and refreshes its own tokens, so callers just ask for a token whenever they need one. + +```go +import "github.com/stackitcloud/stackit-sdk-go/core/credentials" +``` + +- [Quick start](#quick-start) +- [Providers](#providers) +- [The default chain](#the-default-chain) +- [Combining providers](#combining-providers) +- [Scopes and resources](#scopes-and-resources) +- [Caching and concurrency](#caching-and-concurrency) +- [Getting the raw token](#getting-the-raw-token) +- [Logging](#logging) +- [Configuration reference](#configuration-reference) +- [Writing your own provider](#writing-your-own-provider) + +## Quick start + +Pass a provider to any service client with `config.WithTokenProvider`: + +```go +provider, err := credentials.NewDefaultProvider(&credentials.DefaultProviderConfig{}) +if err != nil { + // no credentials could be found, the error says why each flow was left out +} + +client, err := dns.NewAPIClient(config.WithTokenProvider(provider)) +``` + +`config.WithTokenProvider` makes `auth.DefaultAuth` authenticate through the provider instead of the authentication flows that read their configuration from the environment. Options that explicitly choose another way to authenticate (`config.WithToken`, `config.WithServiceAccountKey`, `config.WithCustomAuth`, `config.WithoutAuthentication`, ...) still take precedence over it. + +In production, prefer building the one provider you actually use over the default chain: it is more predictable and easier to debug. + +```go +provider, err := credentials.NewServiceAccountKeyProvider(&credentials.ServiceAccountKeyProviderConfig{ + ServiceAccountKey: key, +}) +``` + +## Providers + +| Provider | Authenticates with | Refreshes | +|---|---|---| +| `WorkloadIdentityFederationProvider` | An OIDC token issued by your platform (for example a Kubernetes service account token), exchanged for a STACKIT token. No secret is stored. | Yes | +| `ServiceAccountKeyProvider` | A service account key and its RSA private key. The provider signs a short-lived assertion and exchanges it for a token. | Yes | +| `StaticTokenProvider` | A token you already have. | No: it is returned until it expires | +| `InstanceMetadataProvider` | The service account attached to a STACKIT VM, through the instance metadata service. | Yes | +| `CLIProvider` | The session of a logged-in STACKIT CLI. Meant for developer machines. | Yes | + +Every provider has a `New…Provider(cfg)` constructor. A constructor returns an error when the provider cannot work (no key, no client ID, CLI not installed, ...), so a provider that was created is ready to use. + +A provider resolves what it needs from **its config first, then from the environment variables, and for some values from the credentials file**, so anything explicit in code wins over the environment. See the [configuration reference](#configuration-reference). + +### Workload identity federation + +```go +provider, err := credentials.NewWorkloadIdentityFederationProvider(&credentials.WorkloadIdentityFederationProviderConfig{ + ClientID: "my-sa@sa.stackit.cloud", // or STACKIT_SERVICE_ACCOUNT_EMAIL +}) +``` + +The platform issues the federated token and the provider only forwards it: it reads the token again at every refresh, from `FederatedTokenFunction` or, when that is nil, from the file in `STACKIT_FEDERATED_TOKEN_FILE` (default `/var/run/secrets/stackit.cloud/serviceaccount/token`). The constructor fails if there is no client ID or the token cannot be read. + +### Service account key + +```go +provider, err := credentials.NewServiceAccountKeyProvider(&credentials.ServiceAccountKeyProviderConfig{ + ServiceAccountKeyPath: "/path/to/service_account_key.json", // or ServiceAccountKey with the JSON itself + PrivateKeyPath: "/path/to/private_key.pem", // only if you provided your own key pair +}) +``` + +Both the key and the private key can be given as content (`ServiceAccountKey`, `PrivateKey`) or as the path of a file (`ServiceAccountKeyPath`, `PrivateKeyPath`). They are looked up in this order, and the first one found is used: + +1. The content in the config. +2. The path in the config. +3. The environment variable with the content (`STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_PRIVATE_KEY`). +4. The environment variable with the path (`STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_PRIVATE_KEY_PATH`). +5. For the private key, the one embedded in the service account key when STACKIT generated the key pair. +6. The credentials file. + +A path that is used but cannot be read makes the constructor fail. The token endpoint is `TokenURL`, then the one in the service account key, then the default STACKIT endpoint. + +### Static token + +```go +provider, err := credentials.NewStaticTokenProvider(&credentials.StaticTokenProviderConfig{Token: token}) +``` + +The token must be a JWT, as its expiration is read from the `exp` claim. It is never refreshed: when it expires, requests fail. + +### Instance metadata + +```go +provider, err := credentials.NewInstanceMetadataProvider(&credentials.InstanceMetadataProviderConfig{ + ServiceAccountEmail: "my-sa@sa.stackit.cloud", +}) +``` + +Only works on a STACKIT VM with a service account attached. + +### CLI session + +```go +provider, err := credentials.NewCLIProvider(&credentials.CLIProviderConfig{}) +``` + +Runs `stackit auth get-access-token`, so it needs the CLI installed and `stackit auth login` done. It authenticates as the person logged in, not as an application, and never starts an interactive login: if the session is not valid it returns an error. + +## The default chain + +`NewDefaultProvider` builds a chain from the credentials it can find, and tries them in this order: + +1. `WorkloadIdentityFederationProvider` +2. `ServiceAccountKeyProvider` +3. `StaticTokenProvider` +4. `InstanceMetadataProvider` +5. `CLIProvider`, **only if enabled** + +The flows that need no secret or issue short-lived tokens come first, and a pre-issued token only applies when none of them is available. The ambient identity of the machine (instance metadata, then CLI) is only used when everything else has failed. + +Flows whose credentials cannot be found are left out of the chain; `NewDefaultProvider` fails only when no flow at all could be configured, and the error lists why each one was skipped. + +The CLI session is **off by default**, because it uses the permissions of a person and a misconfigured service credential could otherwise fall back to them without anyone noticing. Enable it on a developer machine with `DefaultProviderConfig{EnableCLI: true}` or `STACKIT_USE_CLI=true`. + +The chain cannot tell whether a flow was configured in code or in the environment, so a flow set up in the environment can take precedence over a less secure one passed in `DefaultProviderConfig`. + +## Combining providers + +`ChainedProvider` tries providers in order until one returns a token: + +```go +chain, err := credentials.NewChainedProvider(keyProvider, cliProvider) +``` + +By default, once a provider has succeeded the chain keeps using only that one. With `ChainedProviderOptions{RetrySources: true}`, every call tries all the providers in order again. + +## Scopes and resources + +A provider can request scopes and resources, either for every token through its config or for a single call: + +```go +token, err := provider.Token(ctx, credentials.TokenRequestOptions{ + Scopes: []string{"read", "write"}, + Resources: []string{"https://api.example.com"}, +}) +``` + +What a call asks for replaces the configured default. Resources follow [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707): each one becomes an element of the audience (`aud`) of the token. The static token, instance metadata and CLI providers return the same token whatever is asked. + +## Caching and concurrency + +All providers are safe for concurrent use. + +- A provider keeps **one token for each combination of scopes and resources**, so asking for different ones never returns a token issued for another combination. Order and duplicates do not matter: `[b, a, a]` and `[a, b]` are the same request. +- A token is refreshed shortly before it expires (`TokenRefreshLeeway`, one minute by default). +- If several goroutines need a token that is missing or expiring, **a single request is made** and they all receive its result. Errors are not cached. + +Always pass the `context.Context` of the operation: it cancels a request that is waiting for a token. + +## Getting the raw token + +Besides authenticating SDK clients, a provider gives you the access token, which is what data plane APIs and clients outside the SDK need. Build the header with the token's own type instead of assuming `Bearer`: + +```go +token, err := provider.Token(ctx, credentials.TokenRequestOptions{}) +if err != nil { + return err +} +req.Header.Set("Authorization", token.TokenType+" "+token.AccessToken) +``` + +`Token` also carries `ExpiresOn` and `RefreshOn`. + +## Logging + +The package is silent by default and never logs tokens. To see what it does, set a logger: + +```go +credentials.SetLogger(slog.Default()) +``` + +At debug level it reports, for example, why a flow was left out of the default chain and which provider of a chain answered. It logs a warning when the credentials file can be read by other users, and an error when the token endpoint rejects a service account key request. + +## Configuration reference + +| Environment variable | Used by | Meaning | +|---|---|---| +| `STACKIT_SERVICE_ACCOUNT_EMAIL` | WIF, default chain (for instance metadata) | The service account (client ID) | +| `STACKIT_SERVICE_ACCOUNT_TOKEN` | Static token | A pre-issued token | +| `STACKIT_SERVICE_ACCOUNT_KEY` | Service account key | The service account key, as JSON | +| `STACKIT_SERVICE_ACCOUNT_KEY_PATH` | Service account key | Path of a file with the service account key | +| `STACKIT_PRIVATE_KEY` | Service account key | The RSA private key, PEM encoded | +| `STACKIT_PRIVATE_KEY_PATH` | Service account key | Path of a file with the private key | +| `STACKIT_FEDERATED_TOKEN_FILE` | WIF | Path of the file with the OIDC token | +| `STACKIT_IDP_TOKEN_ENDPOINT` | WIF | Token endpoint | +| `STACKIT_CREDENTIALS_PATH` | Static token, service account key, default chain | Credentials file, `~/.stackit/credentials.json` by default | +| `STACKIT_USE_CLI` | Default chain | `true` adds the CLI session to the chain | + +The credentials file is a JSON object that can define `STACKIT_SERVICE_ACCOUNT_TOKEN`, `STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_PRIVATE_KEY` and `STACKIT_SERVICE_ACCOUNT_EMAIL`. It may contain secrets, so keep it readable only by its owner (`chmod 600`); with a logger set, the package warns when it is not. + +All configs also accept an `HTTPClient` for the token requests, and `TokenRefreshLeeway`. Only the transport of the client is used, so a client that already authenticates its requests cannot end up asking for a token to get a token. + +## Writing your own provider + +Anything that implements `TokenProvider` can be used with `config.WithTokenProvider` and in a `ChainedProvider`. Cache the token you obtain, honor the `context`, and set `Token.TokenType` (the SDK assumes `Bearer` when it is empty): + +```go +type myProvider struct{ /* ... */ } + +func (p *myProvider) Token(ctx context.Context, opt credentials.TokenRequestOptions) (credentials.Token, error) { + // get or reuse a token +} +``` diff --git a/core/credentials/auth_constants.go b/core/credentials/auth_constants.go new file mode 100644 index 0000000000..e16576754b --- /dev/null +++ b/core/credentials/auth_constants.go @@ -0,0 +1,21 @@ +package credentials + +// Workload Identity Federation constants +const ( + // WIF client assertion type for JWT bearer token + wifClientAssertionType = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" + // WIF grant type for token requests + wifGrantType = "client_credentials" + // WIF default token endpoint + // nolint:gosec // G101 False positive: This is a constant URL, not a credential + wifDefaultTokenEndpoint = "https://accounts.stackit.cloud/oauth/v2/token" + // WIF default federated token file path in Kubernetes + // nolint:gosec // G101 False positive: This is a constant path, not a credential + wifDefaultFederatedTokenPath = "/var/run/secrets/stackit.cloud/serviceaccount/token" +) + +// Token constants +const ( + // defaultTokenType is the authorization scheme of every token STACKIT issues today. + defaultTokenType = "Bearer" +) diff --git a/core/credentials/chained_provider.go b/core/credentials/chained_provider.go new file mode 100644 index 0000000000..f41393b559 --- /dev/null +++ b/core/credentials/chained_provider.go @@ -0,0 +1,130 @@ +package credentials + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" +) + +const chainedProviderErrorPrefix = "chained provider" + +var _ TokenProvider = (*ChainedProvider)(nil) + +// ChainedProvider is an ordered list of token providers. +type ChainedProvider struct { + cond *sync.Cond + iterating bool + name string + retrySources bool + providers []TokenProvider + successfulProvider TokenProvider +} + +// ChainedProviderOptions contains optional parameters for ChainedProvider. +type ChainedProviderOptions struct { + // RetrySources configures how the chain uses its sources. + // When true, Token always attempts every provider in order until one succeeds. + // When false, after the first success the chain reuses that provider only. + RetrySources bool +} + +// NewChainedProvider creates a chain that tries providers in order until one succeeds. +func NewChainedProvider(providers ...TokenProvider) (*ChainedProvider, error) { + return NewChainedProviderWithOptions(ChainedProviderOptions{}, providers...) +} + +// NewChainedProviderWithOptions creates a chain using the provided options. +func NewChainedProviderWithOptions(options ChainedProviderOptions, providers ...TokenProvider) (*ChainedProvider, error) { + if len(providers) == 0 { + return nil, errors.New("providers must contain at least one TokenProvider") + } + for _, provider := range providers { + if provider == nil { + return nil, errors.New("providers cannot contain nil") + } + } + cp := make([]TokenProvider, len(providers)) + copy(cp, providers) + return &ChainedProvider{ + cond: sync.NewCond(&sync.Mutex{}), + name: "ChainedProvider", + retrySources: options.RetrySources, + providers: cp, + }, nil +} + +// Token returns the first token retrieved successfully from the configured providers. +func (c *ChainedProvider) Token(ctx context.Context, options TokenRequestOptions) (Token, error) { + if !c.retrySources { + // Ensure only one goroutine at a time iterates sources and sets successfulProvider. + c.cond.L.Lock() + for { + if c.successfulProvider != nil { + provider := c.successfulProvider + c.cond.L.Unlock() + return provider.Token(ctx, options) + } + if !c.iterating { + c.iterating = true + c.cond.L.Unlock() + break + } + c.cond.Wait() + } + } + + errorsByProvider := make([]string, 0, len(c.providers)) + var ( + err error + token Token + successfulProvider TokenProvider + ) + for index, provider := range c.providers { + token, err = provider.Token(ctx, options) + if err == nil { + if token.AccessToken == "" { + msg := fmt.Sprintf("%s (%d): returned empty access token", providerName(provider), index) + errorsByProvider = append(errorsByProvider, msg) + debugContext(ctx, "credentials: provider returned empty access token", "chain", c.name, "provider", providerName(provider), "index", index) + err = fmt.Errorf("%s: %s", chainedProviderErrorPrefix, msg) + continue + } + debugContext(ctx, "credentials: authenticated", "chain", c.name, "provider", providerName(provider)) + successfulProvider = provider + break + } + msg := fmt.Sprintf("%s (%d): %v", providerName(provider), index, err) + debugContext(ctx, "credentials: provider failed", "chain", c.name, "provider", providerName(provider), "index", index, "error", err) + errorsByProvider = append(errorsByProvider, msg) + } + + if c.iterating { + c.cond.L.Lock() + c.successfulProvider = successfulProvider + c.iterating = false + c.cond.L.Unlock() + c.cond.Broadcast() + } + + if err == nil && successfulProvider != nil { + return token, nil + } + debugContext(ctx, "credentials: all chain providers failed", "chain", c.name, "errors", strings.Join(errorsByProvider, "; ")) + return Token{}, fmt.Errorf("%s: all chain providers failed: %s", chainedProviderErrorPrefix, strings.Join(errorsByProvider, "; ")) +} + +// providerName returns a short human-readable name for a TokenProvider, +// stripping the package path and pointer sigil so the log is readable. +// +// For example: *credentials.ServiceAccountKeyProvider → "ServiceAccountKeyProvider" +func providerName(p TokenProvider) string { + raw := fmt.Sprintf("%T", p) + // strip leading "*" and package prefix (e.g. "credentials.") + raw = strings.TrimPrefix(raw, "*") + if dot := strings.LastIndex(raw, "."); dot >= 0 { + raw = raw[dot+1:] + } + return raw +} diff --git a/core/credentials/chained_provider_test.go b/core/credentials/chained_provider_test.go new file mode 100644 index 0000000000..b5a1430b48 --- /dev/null +++ b/core/credentials/chained_provider_test.go @@ -0,0 +1,143 @@ +package credentials + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" +) + +type staticProvider struct { + token Token + err error +} + +func (s *staticProvider) Token(context.Context, TokenRequestOptions) (Token, error) { + if s.err != nil { + return Token{}, s.err + } + return s.token, nil +} + +func TestChainToken(t *testing.T) { + expected := Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)} + chain, err := NewChainedProvider( + &staticProvider{err: fmt.Errorf("first failed")}, + &staticProvider{token: expected}, + ) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + token, err := chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token.AccessToken != expected.AccessToken { + t.Fatalf("unexpected access token: %s", token.AccessToken) + } +} + +func TestChainTokenAllFailures(t *testing.T) { + chain, err := NewChainedProvider( + &staticProvider{err: fmt.Errorf("first failed")}, + &staticProvider{err: fmt.Errorf("second failed")}, + ) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "all chain providers failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChainNoProviders(t *testing.T) { + _, err := NewChainedProvider() + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "at least one") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestChainCachesSuccessfulProviderByDefault(t *testing.T) { + var firstCalls int32 + var secondCalls int32 + + first := &staticProvider{err: fmt.Errorf("first failed")} + second := &staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + + countingFirst := countingProvider{inner: first, count: &firstCalls} + countingSecond := countingProvider{inner: second, count: &secondCalls} + + chain, err := NewChainedProvider(countingFirst, countingSecond) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + + if atomic.LoadInt32(&firstCalls) != 1 { + t.Fatalf("expected first provider to be called once, got %d", firstCalls) + } + if atomic.LoadInt32(&secondCalls) != 2 { + t.Fatalf("expected successful provider to be called twice, got %d", secondCalls) + } +} + +func TestChainRetrySourcesOption(t *testing.T) { + var firstCalls int32 + var secondCalls int32 + + first := &staticProvider{err: fmt.Errorf("first failed")} + second := &staticProvider{token: Token{AccessToken: "token", RefreshOn: time.Now().Add(time.Hour)}} + + countingFirst := countingProvider{inner: first, count: &firstCalls} + countingSecond := countingProvider{inner: second, count: &secondCalls} + + chain, err := NewChainedProviderWithOptions(ChainedProviderOptions{RetrySources: true}, countingFirst, countingSecond) + if err != nil { + t.Fatalf("new chain: %v", err) + } + + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + _, err = chain.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + + if atomic.LoadInt32(&firstCalls) != 2 { + t.Fatalf("expected first provider to be called twice, got %d", firstCalls) + } + if atomic.LoadInt32(&secondCalls) != 2 { + t.Fatalf("expected second provider to be called twice, got %d", secondCalls) + } +} + +type countingProvider struct { + inner TokenProvider + count *int32 +} + +func (c countingProvider) Token(ctx context.Context, options TokenRequestOptions) (Token, error) { + atomic.AddInt32(c.count, 1) + return c.inner.Token(ctx, options) +} diff --git a/core/credentials/cli_provider.go b/core/credentials/cli_provider.go new file mode 100644 index 0000000000..b85f5ebd50 --- /dev/null +++ b/core/credentials/cli_provider.go @@ -0,0 +1,173 @@ +package credentials + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + cliProviderErrorPrefix = "cli provider" + cliDefaultCommand = "stackit" + cliDefaultTimeout = 30 * time.Second + cliDefaultLeeway = time.Minute +) + +// cliExecutor runs the STACKIT CLI and returns its standard output. +// It is a field on the provider so tests can fake invoking the CLI. +type cliExecutor func(ctx context.Context, name string, args ...string) ([]byte, error) + +// cliAccessTokenResponse is what `stackit auth get-access-token --output-format json` prints. +type cliAccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` +} + +var _ TokenProvider = (*CLIProvider)(nil) + +// CLIProviderConfig contains the configuration for CLIProvider. +type CLIProviderConfig struct { + // Command is the STACKIT CLI executable to invoke. If empty, "stackit" is + // resolved from PATH. + Command string + // CommandTimeout bounds how long the CLI may take to answer. + // If zero, defaults to 30 seconds. + CommandTimeout time.Duration + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + + // exec is used by tests to fake invoking the CLI. + exec cliExecutor +} + +// CLIProvider reuses the session of a logged-in STACKIT CLI by invoking +// `stackit auth get-access-token`. It exists so that a developer who has run +// `stackit auth login` can use the SDK, and tools built on it, without +// provisioning any separate credential. +// +// This is a developer workstation credential. It requires the CLI to be +// installed and logged in, so it is unavailable in CI, containers and on +// servers — there NewCLIProvider fails and a chain simply skips it. +// +// The provider never starts an interactive login itself: if the CLI has no +// valid session, it reports an error rather than opening a browser. +type CLIProvider struct { + name string + command string + commandTimeout time.Duration + tokenLeeway time.Duration + exec cliExecutor + + cache tokenCache +} + +// NewCLIProvider creates a CLIProvider. It returns an error when the CLI is not +// installed, so that a chain can skip it without paying a process spawn on every +// token request. +func NewCLIProvider(cfg *CLIProviderConfig) (*CLIProvider, error) { + if cfg == nil { + cfg = &CLIProviderConfig{} + } + + command := cfg.Command + if command == "" { + command = cliDefaultCommand + } + + execute := cfg.exec + if execute == nil { + // Resolve the executable once, here, rather than on every token request: + // it turns "the CLI is not installed" into a construction error and keeps + // a later PATH change from silently swapping the binary underneath us. + path, err := exec.LookPath(command) + if err != nil { + return nil, fmt.Errorf("%s: %q not found: %w", cliProviderErrorPrefix, command, err) + } + command = path + execute = runCLICommand + } + + timeout := cfg.CommandTimeout + if timeout == 0 { + timeout = cliDefaultTimeout + } + + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = cliDefaultLeeway + } + + return &CLIProvider{ + name: "CLIProvider", + command: command, + commandTimeout: timeout, + tokenLeeway: leeway, + exec: execute, + }, nil +} + +// Token returns a valid access token from the CLI session. +func (p *CLIProvider) Token(ctx context.Context, _ TokenRequestOptions) (Token, error) { + // These sources issue one token regardless of scopes and resources. + return p.cache.get(ctx, "", p.tokenLeeway, func(ctx context.Context) (Token, error) { + fresh, err := p.requestToken(ctx) + if err != nil { + return Token{}, err + } + debugContext(ctx, "credentials: authenticated", "provider", p.name) + return fresh, nil + }) +} + +func (p *CLIProvider) requestToken(ctx context.Context) (Token, error) { + ctx, cancel := context.WithTimeout(ctx, p.commandTimeout) + defer cancel() + + output, err := p.exec(ctx, p.command, "auth", "get-access-token", "--output-format", "json") + if err != nil { + return Token{}, fmt.Errorf("%s: %w", cliProviderErrorPrefix, err) + } + + var response cliAccessTokenResponse + if err := json.Unmarshal(bytes.TrimSpace(output), &response); err != nil { + // Deliberately not including the output in the error: it is the token on success. + return Token{}, fmt.Errorf("%s: parse access token response: %w", cliProviderErrorPrefix, err) + } + if response.AccessToken == "" { + return Token{}, fmt.Errorf("%s: the CLI returned an empty access token", cliProviderErrorPrefix) + } + + expiresOn, err := getTokenExpiration(response.AccessToken, 0) + if err != nil { + return Token{}, fmt.Errorf("%s: %w", cliProviderErrorPrefix, err) + } + + return Token{ + AccessToken: response.AccessToken, + TokenType: tokenType(response.TokenType), + ExpiresOn: expiresOn, + RefreshOn: expiresOn, + }, nil +} + +// runCLICommand is the production cliExecutor. +func runCLICommand(ctx context.Context, name string, args ...string) ([]byte, error) { + var stdout, stderr bytes.Buffer + // The executable is the configured CLI command (default "stackit"), chosen by the caller. + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // G204: the command is configuration, not input + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return nil, fmt.Errorf("%w: %s", err, message) + } + return nil, err + } + return stdout.Bytes(), nil +} diff --git a/core/credentials/cli_provider_test.go b/core/credentials/cli_provider_test.go new file mode 100644 index 0000000000..f6ef1bcd26 --- /dev/null +++ b/core/credentials/cli_provider_test.go @@ -0,0 +1,175 @@ +package credentials + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" +) + +//nolint:gosec // G101 False positive: This is a test JWT token, not a credential +const testCLIAccessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + +// fakeCLI returns an executor that records its invocations and replays the given output. +func fakeCLI(output string, err error, calls *int64) cliExecutor { + return func(_ context.Context, _ string, args ...string) ([]byte, error) { + if calls != nil { + atomic.AddInt64(calls, 1) + } + if err != nil { + return nil, err + } + // Guard the contract with the CLI: the arguments are part of it. + want := "auth get-access-token --output-format json" + if got := strings.Join(args, " "); got != want { + return nil, fmt.Errorf("unexpected arguments: %q", got) + } + return []byte(output), nil + } +} + +func TestCLIProviderToken(t *testing.T) { + var calls int64 + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI(fmt.Sprintf(`{"access_token": %q}`, testCLIAccessToken), nil, &calls), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != testCLIAccessToken { + t.Fatalf("expected %s, got %s", testCLIAccessToken, token.AccessToken) + } + if token.ExpiresOn.IsZero() { + t.Fatalf("expected non-zero expiration") + } + + // A second call must be served from the cache, not by spawning the CLI again. + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err != nil { + t.Fatalf("expected no error: %v", err) + } + if got := atomic.LoadInt64(&calls); got != 1 { + t.Fatalf("expected the CLI to be invoked once, got %d", got) + } +} + +// TestCLIProviderNotInstalled covers the environment this provider is most often in: +// a container or CI runner with no STACKIT CLI. Construction must fail so a chain skips it. +func TestCLIProviderNotInstalled(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + _, err := NewCLIProvider(&CLIProviderConfig{Command: "stackit-does-not-exist"}) + if err == nil { + t.Fatalf("expected an error when the CLI is not installed") + } + if !strings.Contains(err.Error(), "cli provider") { + t.Fatalf("expected a cli provider error, got: %v", err) + } +} + +// TestCLIProviderSessionExpired covers a CLI that is installed but not logged in: the +// provider reports the CLI's own message and never starts an interactive login. +func TestCLIProviderSessionExpired(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI("", errors.New("exit status 1: session expired, please run \"stackit auth login\""), nil), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected an error for an expired session") + } + if !strings.Contains(err.Error(), "session expired") { + t.Fatalf("expected the CLI message to be surfaced, got: %v", err) + } +} + +func TestCLIProviderInvalidOutput(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI("not json", nil, nil), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err == nil { + t.Fatalf("expected an error for unparsable output") + } +} + +func TestCLIProviderEmptyToken(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI(`{"access_token": ""}`, nil, nil), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err == nil { + t.Fatalf("expected an error for an empty access token") + } +} + +// TestCLIProviderHonoursTimeout checks that a hanging CLI cannot block a caller forever. +func TestCLIProviderHonoursTimeout(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + CommandTimeout: time.Millisecond, + exec: func(ctx context.Context, _ string, _ ...string) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }, + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err == nil { + t.Fatalf("expected a timeout error") + } +} + +// TestCLIProviderTokenTypeDefaults covers a source that reports no token_type. +func TestCLIProviderTokenTypeDefaults(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI(fmt.Sprintf(`{"access_token": %q}`, testCLIAccessToken), nil, nil), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.TokenType != "Bearer" { + t.Fatalf("expected Bearer, got %s", token.TokenType) + } +} + +// TestCLIProviderTokenTypeFromResponse is the case that motivates the field: a token that +// is not a Bearer token, such as RFC 9449 (DPoP), must be reported as issued. +func TestCLIProviderTokenTypeFromResponse(t *testing.T) { + provider, err := NewCLIProvider(&CLIProviderConfig{ + exec: fakeCLI(fmt.Sprintf(`{"access_token": %q, "token_type": "DPoP"}`, testCLIAccessToken), nil, nil), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.TokenType != "DPoP" { + t.Fatalf("expected DPoP, got %s", token.TokenType) + } +} diff --git a/core/credentials/credentials.go b/core/credentials/credentials.go new file mode 100644 index 0000000000..3e38cea715 --- /dev/null +++ b/core/credentials/credentials.go @@ -0,0 +1,79 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// credentialsFile represents the structure of the credentials file +type credentialsFile struct { + ServiceAccountEmail string `json:"STACKIT_SERVICE_ACCOUNT_EMAIL"` + ServiceAccountToken string `json:"STACKIT_SERVICE_ACCOUNT_TOKEN"` + ServiceAccountKeyPath string `json:"STACKIT_SERVICE_ACCOUNT_KEY_PATH"` + PrivateKeyPath string `json:"STACKIT_PRIVATE_KEY_PATH"` + ServiceAccountKey string `json:"STACKIT_SERVICE_ACCOUNT_KEY"` + PrivateKey string `json:"STACKIT_PRIVATE_KEY"` +} + +const ( + // nolint:gosec // G101 False positive: This is a constant path, not a credential + credentialsFilePath = ".stackit/credentials.json" +) + +// readCredentialsFile reads the credentials file from the specified path +func readCredentialsFile(path string) (*credentialsFile, error) { + if path == "" { + customPath, customPathSet := os.LookupEnv(envCredentialsPath) + if !customPathSet || customPath == "" { + path = credentialsFilePath + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("getting home directory: %w", err) + } + path = filepath.Join(home, path) + } else { + path = customPath + } + } + + warnOnInsecureCredentialsFile(path) + + credentialsRaw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("opening file: %w", err) + } + + var credentials credentialsFile + err = json.Unmarshal(credentialsRaw, &credentials) + if err != nil { + return nil, fmt.Errorf("unmarshalling credentials: %w", err) + } + return &credentials, nil +} + +// warnOnInsecureCredentialsFile warns when the credentials file can be read by users other +// than its owner. The file may hold a service account token or a private key in clear text, +// and nothing creates it automatically, so its permissions are whatever the user's umask +// produced. This only warns: the format predates this package and is shared with the other +// STACKIT SDKs and the Terraform provider, so refusing to read it would break callers. +// Logging is opt-in, so this is silent unless SetLogger was called. +func warnOnInsecureCredentialsFile(path string) { + if runtime.GOOS == "windows" { + // Go synthesizes permission bits on Windows, so they say nothing about access. + return + } + info, err := os.Stat(path) + if err != nil { + // Let the read report the real problem. + return + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + warnContext(context.Background(), + "credentials: credentials file is readable by other users", + "path", path, "permissions", fmt.Sprintf("%#o", perm), "recommended", "0600") + } +} diff --git a/core/credentials/credentials_test.go b/core/credentials/credentials_test.go new file mode 100644 index 0000000000..5823b0db66 --- /dev/null +++ b/core/credentials/credentials_test.go @@ -0,0 +1,91 @@ +package credentials + +import ( + "bytes" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// captureLogs installs a logger for the duration of the test and returns its buffer. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + SetLogger(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + t.Cleanup(func() { SetLogger(nil) }) + return buf +} + +func writeCredentialsFile(t *testing.T, perm os.FileMode) string { + t.Helper() + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + content, err := json.Marshal(credentialsFile{ServiceAccountToken: token}) + if err != nil { + t.Fatalf("marshal credentials: %v", err) + } + path := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(path, content, perm); err != nil { + t.Fatalf("write credentials file: %v", err) + } + // WriteFile applies the umask, so set the mode explicitly. + if err := os.Chmod(path, perm); err != nil { + t.Fatalf("chmod credentials file: %v", err) + } + return path +} + +func TestReadCredentialsFileWarnsOnLoosePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are synthesized on Windows") + } + logs := captureLogs(t) + + path := writeCredentialsFile(t, 0o644) + if _, err := readCredentialsFile(path); err != nil { + t.Fatalf("expected no error: %v", err) + } + + if !strings.Contains(logs.String(), "readable by other users") { + t.Fatalf("expected a warning for a world-readable credentials file, got: %q", logs.String()) + } +} + +func TestReadCredentialsFileSilentOnTightPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are synthesized on Windows") + } + logs := captureLogs(t) + + path := writeCredentialsFile(t, 0o600) + if _, err := readCredentialsFile(path); err != nil { + t.Fatalf("expected no error: %v", err) + } + + if logs.Len() != 0 { + t.Fatalf("expected no warning for a 0600 credentials file, got: %q", logs.String()) + } +} + +// TestReadCredentialsFileWarningDoesNotLeakToken guards the security property that matters: +// the warning must name the file, never its contents. +func TestReadCredentialsFileWarningDoesNotLeakToken(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are synthesized on Windows") + } + logs := captureLogs(t) + + path := writeCredentialsFile(t, 0o644) + credentials, err := readCredentialsFile(path) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if strings.Contains(logs.String(), credentials.ServiceAccountToken) { + t.Fatalf("the warning leaked the token") + } +} diff --git a/core/credentials/default_provider.go b/core/credentials/default_provider.go new file mode 100644 index 0000000000..1b55196f37 --- /dev/null +++ b/core/credentials/default_provider.go @@ -0,0 +1,220 @@ +package credentials + +import ( + "context" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" +) + +const defaultProviderErrorPrefix = "default provider" + +// DefaultProviderConfig contains the configuration for NewDefaultProvider. +// +// Every field is optional. Each provider in the chain resolves what it needs from this +// config first, then from environment variables, and finally from the credentials file. +// Providers whose credentials cannot be resolved are left out of the chain rather than +// failing construction. +type DefaultProviderConfig struct { + // Token is a pre-issued access token. If empty, STACKIT_SERVICE_ACCOUNT_TOKEN is used. + Token string + // ServiceAccountKey is the service account key as a JSON string. If empty, + // ServiceAccountKeyPath is used, then STACKIT_SERVICE_ACCOUNT_KEY and + // STACKIT_SERVICE_ACCOUNT_KEY_PATH. + ServiceAccountKey string + // ServiceAccountKeyPath is the path of a file with the service account key. It is used + // when ServiceAccountKey is empty. + ServiceAccountKeyPath string + // PrivateKey is the RSA private key as a PEM-encoded string. If empty, + // PrivateKeyPath is used, then STACKIT_PRIVATE_KEY and STACKIT_PRIVATE_KEY_PATH. + PrivateKey string + // PrivateKeyPath is the path of a file with the PEM-encoded RSA private key. It is used + // when PrivateKey is empty. + PrivateKeyPath string + // ServiceAccountEmail identifies the service account for workload identity + // federation and for the instance metadata service. If empty, it is resolved from + // STACKIT_SERVICE_ACCOUNT_EMAIL and then from the credentials file. + ServiceAccountEmail string + // FederatedTokenFunction provides the OIDC assertion for workload identity + // federation. If nil, the assertion is read from STACKIT_FEDERATED_TOKEN_FILE. + FederatedTokenFunction oidcadapters.OIDCTokenFunc + // TokenURL overrides the token endpoint for every provider in the chain. + TokenURL string + // CredentialsFilePath overrides the credentials file location. If empty, the path from + // STACKIT_CREDENTIALS_PATH is used, falling back to ~/.stackit/credentials.json. + CredentialsFilePath string + // TokenRefreshLeeway controls how early before expiration tokens are refreshed. + // If zero, each provider applies its own default. + TokenRefreshLeeway time.Duration + // CLICommand is the STACKIT CLI executable used to reuse a local CLI session. + // If empty, "stackit" is resolved from PATH. + CLICommand string + // EnableCLI adds the session of a logged-in STACKIT CLI to the end of the chain. It is + // meant for developer machines and is off by default, since it authenticates as the + // person logged in to the CLI rather than as the application. If false, it can also be + // enabled with STACKIT_USE_CLI=true. + EnableCLI bool + // HTTPClient is used for token requests. If nil, a default client is used. + HTTPClient *http.Client + // Scopes are the optional OAuth2 scopes to request for the access token. + Scopes []string + // Resources are the optional resource identifiers to request access for. + Resources []string +} + +// NewDefaultProvider creates the opinionated credential chain STACKIT ships out of the box. +// It tries, in order: +// +// 1. WorkloadIdentityFederationProvider — workload identity federation. +// 2. ServiceAccountKeyProvider — the service account key flow. +// 3. StaticTokenProvider — a pre-issued token from config, environment or credentials file. +// 4. InstanceMetadataProvider — the token of the service account attached to a STACKIT VM. +// 5. CLIProvider — the session of a logged-in STACKIT CLI, for developer machines. +// +// The most secure flows come first: workload identity federation needs no secret and issues +// short-lived tokens, the service account key flow issues short-lived tokens from a +// long-lived key, and a pre-issued token is the least secure, so it only applies when +// neither is available. The chain cannot tell whether a flow was configured in code or in +// the environment, so a flow set up in the environment can take precedence over a less +// secure one passed in DefaultProviderConfig. +// +// Credentials of the application therefore always take precedence over the ambient +// identity of the machine, which is only consulted once everything else has failed. This +// mirrors the credential chains of the other major cloud SDKs, where the instance metadata +// service is present but last (AWS EC2 IMDS, GCP metadata server, Azure managed identity). +// Placing it late also means its network probe is only paid as a last resort, so callers +// who are not running on a STACKIT VM do not wait on it. +// +// The CLI session is opt-in (DefaultProviderConfig.EnableCLI or STACKIT_USE_CLI=true) and +// goes last, as in the other SDKs, whose chains end with the local developer tooling +// (Azure's AzureCLICredential, GCP's gcloud credentials). It is not on by default because +// it authenticates as the person logged in rather than as the application, so a +// misconfigured service credential would otherwise fall back to a person's permissions +// without anyone noticing. Note that no interactive login is part of this chain: a default +// chain that can open a browser would be a poor surprise in CI or in a script, so obtaining +// a session interactively stays an explicit, opt-in operation. +// +// Providers whose credentials cannot be resolved are skipped; an error is returned only when +// no provider at all could be configured, listing why each one was skipped. +// +// The default chain is a convenience for getting started. In production, constructing the +// single provider you actually use makes authentication more predictable and easier to debug. +func NewDefaultProvider(cfg *DefaultProviderConfig) (*ChainedProvider, error) { + if cfg == nil { + cfg = &DefaultProviderConfig{} + } + var ( + providers []TokenProvider + skipped []string + ) + // The credentials file is read once for the whole chain, not once per provider. + file := newCredentialsFileSource(cfg.CredentialsFilePath) + add := func(name string, provider TokenProvider, err error) { + if err != nil { + skipped = append(skipped, fmt.Sprintf("%s: %v", name, err)) + debugContext(context.Background(), "credentials: provider left out of the default chain", "provider", name, "reason", err.Error()) + return + } + providers = append(providers, provider) + } + + wifProvider, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ + TokenURL: cfg.TokenURL, + ClientID: cfg.ServiceAccountEmail, + FederatedTokenFunction: cfg.FederatedTokenFunction, + TokenRefreshLeeway: cfg.TokenRefreshLeeway, + HTTPClient: cfg.HTTPClient, + Scopes: cfg.Scopes, + Resources: cfg.Resources, + }) + add("WorkloadIdentityFederationProvider", wifProvider, err) + + keyProvider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: cfg.ServiceAccountKey, + ServiceAccountKeyPath: cfg.ServiceAccountKeyPath, + PrivateKey: cfg.PrivateKey, + PrivateKeyPath: cfg.PrivateKeyPath, + TokenURL: cfg.TokenURL, + TokenRefreshLeeway: cfg.TokenRefreshLeeway, + HTTPClient: cfg.HTTPClient, + CredentialsFilePath: cfg.CredentialsFilePath, + Scopes: cfg.Scopes, + Resources: cfg.Resources, + credentialsFile: file, + }) + add("ServiceAccountKeyProvider", keyProvider, err) + + staticProvider, err := NewStaticTokenProvider(&StaticTokenProviderConfig{ + Token: cfg.Token, + CredentialsFilePath: cfg.CredentialsFilePath, + credentialsFile: file, + }) + add("StaticTokenProvider", staticProvider, err) + + // Ambient VM identity goes last: it must never outrank credentials the caller + // configured, and its probe is only worth paying once nothing else has worked. + metadataProvider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ + ServiceAccountEmail: serviceAccountEmail(cfg, file), + TokenRefreshLeeway: cfg.TokenRefreshLeeway, + HTTPClient: cfg.HTTPClient, + }) + add("InstanceMetadataProvider", metadataProvider, err) + + // The CLI session is the developer-machine fallback, so it goes last. It is opt-in, and + // is skipped wherever the CLI is not installed, which is every CI runner, container and + // server. + if reason := cliStepDisabledReason(cfg); reason != "" { + skipped = append(skipped, "CLIProvider: "+reason) + debugContext(context.Background(), "credentials: provider left out of the default chain", "provider", "CLIProvider", "reason", reason) + } else { + cliProvider, err := NewCLIProvider(&CLIProviderConfig{ + Command: cfg.CLICommand, + TokenRefreshLeeway: cfg.TokenRefreshLeeway, + }) + add("CLIProvider", cliProvider, err) + } + + if len(providers) == 0 { + return nil, fmt.Errorf("%s: no valid credentials were found: %s", defaultProviderErrorPrefix, strings.Join(skipped, "; ")) + } + + chain, err := NewChainedProvider(providers...) + if err != nil { + return nil, fmt.Errorf("%s: %w", defaultProviderErrorPrefix, err) + } + chain.name = "DefaultProvider" + + return chain, nil +} + +// serviceAccountEmail resolves the service account email from config, then the +// environment, then the credentials file, matching how the other providers in the chain +// resolve their own credentials. InstanceMetadataProvider, unlike +// WorkloadIdentityFederationProvider, does not do this resolution itself. +func serviceAccountEmail(cfg *DefaultProviderConfig, file *credentialsFileSource) string { + email, _ := resolveValue(cfg.ServiceAccountEmail, "", envServiceAccountEmail, "", + fromFile(file, func(c *credentialsFile) string { return c.ServiceAccountEmail })) + return email +} + +// cliStepDisabledReason reports why the default chain leaves out the CLI session step, or +// an empty string when the step is included. The step is opt-in: it is included when +// DefaultProviderConfig.EnableCLI is set or, failing that, when STACKIT_USE_CLI is true. +// Nothing turns it off once it has been enabled, so the code never overrides the environment +// nor the other way around. A value of STACKIT_USE_CLI that is not a boolean leaves it off. +// +// Constructing a CLIProvider directly is unaffected — an explicit choice is not overridden. +func cliStepDisabledReason(cfg *DefaultProviderConfig) string { + if cfg.EnableCLI { + return "" + } + if enabled, err := strconv.ParseBool(os.Getenv(envUseCLI)); err == nil && enabled { + return "" + } + return fmt.Sprintf("not enabled (set DefaultProviderConfig.EnableCLI or %s=true)", envUseCLI) +} diff --git a/core/credentials/default_provider_test.go b/core/credentials/default_provider_test.go new file mode 100644 index 0000000000..3780a48c37 --- /dev/null +++ b/core/credentials/default_provider_test.go @@ -0,0 +1,435 @@ +package credentials + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// clearCredentialEnv removes every credential env var so the tests do not pick up +// ambient credentials from the machine running them. +func clearCredentialEnv(t *testing.T) { + t.Helper() + for _, env := range []string{ + "STACKIT_SERVICE_ACCOUNT_TOKEN", + "STACKIT_SERVICE_ACCOUNT_KEY", + "STACKIT_SERVICE_ACCOUNT_KEY_PATH", + "STACKIT_PRIVATE_KEY", + "STACKIT_PRIVATE_KEY_PATH", + "STACKIT_SERVICE_ACCOUNT_EMAIL", + "STACKIT_FEDERATED_TOKEN_FILE", + "STACKIT_USE_CLI", + } { + t.Setenv(env, "") + } + isolateCredentialsFile(t) + // Empty PATH so CLIProvider is deterministically skipped: whether the chain includes + // it must not depend on the STACKIT CLI being installed on the machine running tests. + t.Setenv("PATH", t.TempDir()) +} + +// isolateCredentialsFile points the credentials file at a path that does not exist, so +// tests never pick up a real ~/.stackit/credentials.json from the machine running them. +func isolateCredentialsFile(t *testing.T) { + t.Helper() + t.Setenv("STACKIT_CREDENTIALS_PATH", filepath.Join(t.TempDir(), "no-such-credentials.json")) +} + +func TestDefaultProviderNoCredentials(t *testing.T) { + clearCredentialEnv(t) + + _, err := NewDefaultProvider(&DefaultProviderConfig{}) + if err == nil { + t.Fatalf("expected error when no credentials are available") + } + // The error must explain why each provider was skipped, not just that it failed. + for _, want := range []string{"StaticTokenProvider", "ServiceAccountKeyProvider", "WorkloadIdentityFederationProvider", "CLIProvider"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error to mention %s, got: %v", want, err) + } + } +} + +func TestDefaultProviderStaticToken(t *testing.T) { + clearCredentialEnv(t) + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + provider, err := NewDefaultProvider(&DefaultProviderConfig{Token: accessToken}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != accessToken { + t.Fatalf("expected %s, got %s", accessToken, token.AccessToken) + } +} + +// TestDefaultProviderPrefersServiceAccountKeyOverStatic pins the documented order: +// the service account key flow wins over a pre-issued token. +func TestDefaultProviderPrefersServiceAccountKeyOverStatic(t *testing.T) { + clearCredentialEnv(t) + t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", filepath.Join(t.TempDir(), "no-such-token")) + + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "from-key-flow", "expires_in": 3600}) + })) + defer server.Close() + + saKeyJSON, err := json.Marshal(serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + }) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + Token: accessToken, + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if len(provider.providers) != 2 { + t.Fatalf("expected 2 providers in the chain, got %d", len(provider.providers)) + } + if _, ok := provider.providers[0].(*ServiceAccountKeyProvider); !ok { + t.Fatalf("expected ServiceAccountKeyProvider first, got %T", provider.providers[0]) + } + if _, ok := provider.providers[1].(*StaticTokenProvider); !ok { + t.Fatalf("expected StaticTokenProvider second, got %T", provider.providers[1]) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != "from-key-flow" { + t.Fatalf("expected the key flow token to win, got %s", token.AccessToken) + } +} + +// TestDefaultProviderPrefersWorkloadIdentityFederationOverServiceAccountKey pins that the +// secretless flow outranks the one based on a long-lived key. +func TestDefaultProviderPrefersWorkloadIdentityFederationOverServiceAccountKey(t *testing.T) { + clearCredentialEnv(t) + + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + saKeyJSON, err := json.Marshal(serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{Aud: "aud", Iss: "iss", Kid: "kid", Sub: uuid.New()}, + }) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + ServiceAccountEmail: "service-account@sa.stackit.cloud", + FederatedTokenFunction: func(context.Context) (string, error) { return "federated-token", nil }, + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + providers := provider.providers + if _, ok := providers[0].(*WorkloadIdentityFederationProvider); !ok { + t.Fatalf("expected WorkloadIdentityFederationProvider first, got %T", providers[0]) + } + if _, ok := providers[1].(*ServiceAccountKeyProvider); !ok { + t.Fatalf("expected ServiceAccountKeyProvider second, got %T", providers[1]) + } +} + +// TestDefaultProviderInstanceMetadataAfterConfigured pins that the ambient VM identity is +// part of the chain but never outranks explicitly configured credentials. +func TestDefaultProviderInstanceMetadataAfterConfigured(t *testing.T) { + clearCredentialEnv(t) + // Point the federated token file at a path that does not exist so workload identity + // federation is deterministically skipped, whatever the machine running the test. + t.Setenv("STACKIT_FEDERATED_TOKEN_FILE", filepath.Join(t.TempDir(), "no-such-token")) + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + Token: accessToken, + ServiceAccountEmail: "service-account@sa.stackit.cloud", + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + providers := provider.providers + if len(providers) != 2 { + t.Fatalf("expected 2 providers in the chain, got %d", len(providers)) + } + if _, ok := providers[0].(*StaticTokenProvider); !ok { + t.Fatalf("expected StaticTokenProvider first, got %T", providers[0]) + } + if _, ok := providers[1].(*InstanceMetadataProvider); !ok { + t.Fatalf("expected InstanceMetadataProvider after the configured credentials, got %T", providers[1]) + } + + // The metadata endpoint is link-local and unreachable here; the static token must win + // without it ever being contacted. + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != accessToken { + t.Fatalf("expected the static token to win, got %s", token.AccessToken) + } +} + +func TestDefaultProviderResolvesTokenFromEnvironment(t *testing.T) { + clearCredentialEnv(t) + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + t.Setenv("STACKIT_SERVICE_ACCOUNT_TOKEN", accessToken) + + provider, err := NewDefaultProvider(&DefaultProviderConfig{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != accessToken { + t.Fatalf("expected %s, got %s", accessToken, token.AccessToken) + } +} + +// TestDefaultProviderResolvesTokenFromCredentialsFile covers the third step of the +// documented resolution order: config, then environment, then the credentials file. The +// caller does not have to opt into the file by setting CredentialsFilePath. +func TestDefaultProviderResolvesTokenFromCredentialsFile(t *testing.T) { + clearCredentialEnv(t) + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + credentialsPath := filepath.Join(t.TempDir(), "credentials.json") + credentials, err := json.Marshal(credentialsFile{ServiceAccountToken: accessToken}) + if err != nil { + t.Fatalf("marshal credentials: %v", err) + } + if err := os.WriteFile(credentialsPath, credentials, 0o600); err != nil { + t.Fatalf("write credentials file: %v", err) + } + t.Setenv("STACKIT_CREDENTIALS_PATH", credentialsPath) + + provider, err := NewDefaultProvider(&DefaultProviderConfig{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != accessToken { + t.Fatalf("expected the token from the credentials file, got %s", token.AccessToken) + } +} + +// TestDefaultProviderCLIIsLast pins the last step of the documented chain: the CLI session +// is available but never outranks anything else. +func TestDefaultProviderCLIIsLast(t *testing.T) { + clearCredentialEnv(t) + + // A stand-in for the STACKIT CLI, so the test does not depend on it being installed. + fakeCLIPath := filepath.Join(t.TempDir(), "stackit") + if err := os.WriteFile(fakeCLIPath, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { //nolint:gosec // G306: the fake CLI has to be executable + t.Fatalf("write fake cli: %v", err) + } + + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + Token: testCLIAccessToken, + CLICommand: fakeCLIPath, + EnableCLI: true, + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + providers := provider.providers + if len(providers) != 2 { + t.Fatalf("expected 2 providers in the chain, got %d", len(providers)) + } + if _, ok := providers[0].(*StaticTokenProvider); !ok { + t.Fatalf("expected StaticTokenProvider first, got %T", providers[0]) + } + if _, ok := providers[len(providers)-1].(*CLIProvider); !ok { + t.Fatalf("expected CLIProvider last, got %T", providers[len(providers)-1]) + } + + // The fake CLI always fails, so the static token must win without it being invoked. + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if token.AccessToken != testCLIAccessToken { + t.Fatalf("expected the static token to win, got %s", token.AccessToken) + } +} + +// TestDefaultProviderCLIOptIn covers the two ways to enable the CLI session step, and that +// it is off by default. +func TestDefaultProviderCLIOptIn(t *testing.T) { + fakeCLIPath := filepath.Join(t.TempDir(), "stackit") + if err := os.WriteFile(fakeCLIPath, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { //nolint:gosec // G306: the fake CLI has to be executable + t.Fatalf("write fake cli: %v", err) + } + + tests := []struct { + name string + env string + enableCLI bool + wantCLI bool + }{ + {name: "off by default", wantCLI: false}, + {name: "enabled in code", enableCLI: true, wantCLI: true}, + {name: "enabled in the environment", env: "true", wantCLI: true}, + {name: "environment false leaves it off", env: "false", wantCLI: false}, + {name: "a value that is not a boolean leaves it off", env: "not-a-bool", wantCLI: false}, + {name: "code wins over an environment false", env: "false", enableCLI: true, wantCLI: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clearCredentialEnv(t) + t.Setenv("STACKIT_USE_CLI", test.env) + + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + Token: testCLIAccessToken, + CLICommand: fakeCLIPath, + EnableCLI: test.enableCLI, + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + var found bool + for _, p := range provider.providers { + if _, ok := p.(*CLIProvider); ok { + found = true + } + } + if found != test.wantCLI { + t.Fatalf("expected CLIProvider present=%v, got %v", test.wantCLI, found) + } + }) + } +} + +func TestDefaultProviderServiceAccountKeyFromPaths(t *testing.T) { + clearCredentialEnv(t) + privateKeyPEM, _ := newRSAKey(t) + tokenURL, assertion := keyResolutionServer(t) + + provider, err := NewDefaultProvider(&DefaultProviderConfig{ + ServiceAccountKeyPath: writeTestFile(t, serviceAccountKeyJSONFor(t, "from-path")), + PrivateKeyPath: writeTestFile(t, privateKeyPEM), + TokenURL: tokenURL, + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if _, ok := provider.providers[0].(*ServiceAccountKeyProvider); !ok { + t.Fatalf("expected ServiceAccountKeyProvider first, got %T", provider.providers[0]) + } + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err != nil { + t.Fatalf("token: %v", err) + } + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(assertion(), claims); err != nil { + t.Fatalf("parse assertion: %v", err) + } + if claims["iss"] != "from-path" { + t.Fatalf("expected the key read from the path, got iss %v", claims["iss"]) + } +} + +// TestDefaultProviderReadsCredentialsFileOnce pins that the providers of the default chain +// share one read of the credentials file. Every read of a file that others can read logs a +// warning, so the number of warnings is the number of reads. +func TestDefaultProviderReadsCredentialsFileOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are synthesized on Windows") + } + clearCredentialEnv(t) + logs := captureLogs(t) + + _, err := NewDefaultProvider(&DefaultProviderConfig{ + CredentialsFilePath: writeCredentialsFile(t, 0o644), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if got := strings.Count(logs.String(), "readable by other users"); got != 1 { + t.Fatalf("expected the credentials file to be read once, it was read %d times", got) + } +} + +// TestDefaultProviderDoesNotReadCredentialsFileWhenNotNeeded pins that the file is only read +// when some value is missing from the config and the environment. +func TestDefaultProviderDoesNotReadCredentialsFileWhenNotNeeded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits are synthesized on Windows") + } + clearCredentialEnv(t) + logs := captureLogs(t) + + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + privateKeyPEM, _ := newRSAKey(t) + _, err := NewDefaultProvider(&DefaultProviderConfig{ + Token: accessToken, + ServiceAccountKey: serviceAccountKeyJSONFor(t, "iss"), + PrivateKey: privateKeyPEM, + ServiceAccountEmail: "sa@sa.stackit.cloud", + FederatedTokenFunction: func(context.Context) (string, error) { + return "federated", nil + }, + CredentialsFilePath: writeCredentialsFile(t, 0o644), + }) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + if logs.Len() != 0 { + t.Fatalf("expected the credentials file not to be read, got: %q", logs.String()) + } +} diff --git a/core/credentials/doc.go b/core/credentials/doc.go new file mode 100644 index 0000000000..0a8a5055ad --- /dev/null +++ b/core/credentials/doc.go @@ -0,0 +1,16 @@ +// Package credentials contains modular token providers and composition primitives. +// +// Everything builds on one contract, TokenProvider, which returns an access token and +// nothing else. Each authentication flow ships as its own provider implementing it: +// StaticTokenProvider, ServiceAccountKeyProvider, WorkloadIdentityFederationProvider, +// InstanceMetadataProvider and CLIProvider. ChainedProvider composes providers in order, +// and NewDefaultProvider assembles the opinionated chain the SDK uses by default. +// +// Implementations are safe for concurrent use and cache and refresh their own tokens, so +// callers do not have to. Tokens are never logged; logging is off unless SetLogger is +// called. +// +// Beyond authenticating SDK clients through config.WithTokenProvider, TokenProvider.Token +// returns the raw access token, which is what data plane APIs and non-SDK clients need in +// order to build their own Authorization header. +package credentials diff --git a/core/credentials/env_vars.go b/core/credentials/env_vars.go new file mode 100644 index 0000000000..2dc3fe9a9f --- /dev/null +++ b/core/credentials/env_vars.go @@ -0,0 +1,25 @@ +package credentials + +// Environment variable names for STACKIT authentication +const ( + // Service Account authentication + envServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL" + envServiceAccountToken = "STACKIT_SERVICE_ACCOUNT_TOKEN" + envServiceAccountKey = "STACKIT_SERVICE_ACCOUNT_KEY" + envServiceAccountKeyPath = "STACKIT_SERVICE_ACCOUNT_KEY_PATH" + envPrivateKey = "STACKIT_PRIVATE_KEY" + envPrivateKeyPath = "STACKIT_PRIVATE_KEY_PATH" + // nolint:gosec // G101 False positive: This is a constant path, not a credential + envCredentialsPath = "STACKIT_CREDENTIALS_PATH" + + // Workload Identity Federation + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential + envFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE" + // nolint:gosec // G101 False positive: This is a constant env var name, not a credential + envIdpTokenEndpoint = "STACKIT_IDP_TOKEN_ENDPOINT" + + // Chain control + // envUseCLI adds the CLI session step to the default chain when set to a true value. + // The step is off by default. + envUseCLI = "STACKIT_USE_CLI" +) diff --git a/core/credentials/http_client.go b/core/credentials/http_client.go new file mode 100644 index 0000000000..b1789572c9 --- /dev/null +++ b/core/credentials/http_client.go @@ -0,0 +1,29 @@ +package credentials + +import ( + "net/http" + "time" +) + +// authHTTPClient creates a new http.Client with only the transport from the given client. +// This avoids passing a client that might already have auth roundtrippers attached, +// which would cause deadlocks during token acquisition. +// If timeout > 0, it will be applied to the returned client. +func authHTTPClient(httpClient *http.Client, timeout time.Duration) *http.Client { + var transport http.RoundTripper + + // Extract transport from the provided client if available + if httpClient != nil && httpClient.Transport != nil { + transport = httpClient.Transport + } + + // Create a new clean client with just the transport + client := &http.Client{Transport: transport} + + // Apply timeout if specified + if timeout > 0 { + client.Timeout = timeout + } + + return client +} diff --git a/core/credentials/instance_metadata_provider.go b/core/credentials/instance_metadata_provider.go new file mode 100644 index 0000000000..577889ee76 --- /dev/null +++ b/core/credentials/instance_metadata_provider.go @@ -0,0 +1,132 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + instanceMetadataEndpointTemplate = "http://169.254.169.254/stackit/v1/service-accounts//token" + instanceMetadataPathPlaceholder = "" + instanceMetadataDefaultLeeway = time.Minute + instanceMetadataErrorPrefix = "instance metadata provider" +) + +// InstanceMetadataProviderConfig contains the configuration for InstanceMetadataProvider. +type InstanceMetadataProviderConfig struct { + ServiceAccountEmail string + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client +} + +var _ TokenProvider = (*InstanceMetadataProvider)(nil) + +// InstanceMetadata retrieves access tokens from STACKIT VM instance metadata endpoint. +type InstanceMetadataProvider struct { + name string + httpClient *http.Client + serviceAccount string + endpointTemplate string + tokenLeeway time.Duration + + cache tokenCache +} + +type instanceMetadataTokenResponse struct { + Token string `json:"token"` + ValidUntil string `json:"validUntil"` +} + +// NewInstanceMetadataProvider creates an InstanceMetadata provider. +func NewInstanceMetadataProvider(cfg *InstanceMetadataProviderConfig) (*InstanceMetadataProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", instanceMetadataErrorPrefix) + } + + if cfg.ServiceAccountEmail == "" { + return nil, fmt.Errorf("%s: service account email cannot be empty", instanceMetadataErrorPrefix) + } + + // Create a clean client to avoid deadlocks if the provided client already has auth roundtrippers + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) + + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = instanceMetadataDefaultLeeway + } + + return &InstanceMetadataProvider{ + name: "InstanceMetadataProvider", + httpClient: httpClient, + serviceAccount: cfg.ServiceAccountEmail, + endpointTemplate: instanceMetadataEndpointTemplate, + tokenLeeway: leeway, + }, nil +} + +// Token returns a valid access token. +func (p *InstanceMetadataProvider) Token(ctx context.Context, _ TokenRequestOptions) (Token, error) { + // These sources issue one token regardless of scopes and resources. + return p.cache.get(ctx, "", p.tokenLeeway, func(ctx context.Context) (Token, error) { + fresh, err := p.requestToken(ctx) + if err != nil { + return Token{}, err + } + debugContext(ctx, "credentials: authenticated", "provider", p.name) + return fresh, nil + }) +} + +func (p *InstanceMetadataProvider) requestToken(ctx context.Context) (Token, error) { + url := strings.ReplaceAll(p.endpointTemplate, instanceMetadataPathPlaceholder, p.serviceAccount) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return Token{}, fmt.Errorf("%s: create instance metadata request: %w", instanceMetadataErrorPrefix, err) + } + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request instance metadata token: %w", instanceMetadataErrorPrefix, err) + } + defer func() { + _ = res.Body.Close() + }() + + if res.StatusCode != http.StatusOK { + return Token{}, fmt.Errorf("%s: instance metadata request failed with status %d", instanceMetadataErrorPrefix, res.StatusCode) + } + + var tokenResponse instanceMetadataTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode instance metadata token response: %w", instanceMetadataErrorPrefix, err) + } + if tokenResponse.Token == "" { + return Token{}, fmt.Errorf("%s: instance metadata token response did not include token", instanceMetadataErrorPrefix) + } + if tokenResponse.ValidUntil == "" { + return Token{}, fmt.Errorf("%s: instance metadata token response did not include validUntil", instanceMetadataErrorPrefix) + } + + expiresAt, err := time.Parse(time.RFC3339Nano, tokenResponse.ValidUntil) + if err != nil { + expiresAt, err = time.Parse(time.RFC3339, tokenResponse.ValidUntil) + if err != nil { + return Token{}, fmt.Errorf("%s: parse validUntil timestamp: %w", instanceMetadataErrorPrefix, err) + } + } + + return Token{ + AccessToken: tokenResponse.Token, + // The instance metadata service does not report a token type. + TokenType: defaultTokenType, + ExpiresOn: expiresAt, + RefreshOn: expiresAt, + }, nil +} diff --git a/core/credentials/instance_metadata_provider_test.go b/core/credentials/instance_metadata_provider_test.go new file mode 100644 index 0000000000..cdd209c336 --- /dev/null +++ b/core/credentials/instance_metadata_provider_test.go @@ -0,0 +1,115 @@ +package credentials + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func TestInstanceMetadataTokenCachesResponse(t *testing.T) { + var requests int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if r.Method != http.MethodGet { + t.Fatalf("unexpected method %s", r.Method) + } + // A unique jti makes every response a different token, so two equal tokens can only + // come from the cache. + tokenJWT, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + Subject: "subject", + ID: uuid.NewString(), + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("create token: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": tokenJWT, + "validUntil": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), + }) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token call: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token call: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected same token on cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +func TestInstanceMetadataTokenRequiresServiceAccountEmail(t *testing.T) { + _, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestInstanceMetadataTokenInvalidValidUntil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "token", + "validUntil": "invalid", + }) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestInstanceMetadataTokenHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + provider, err := NewInstanceMetadataProvider(&InstanceMetadataProviderConfig{ + ServiceAccountEmail: "test@sa.stackit.cloud", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("create provider: %v", err) + } + provider.endpointTemplate = server.URL + "/stackit/v1/service-accounts//token" + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/core/credentials/log.go b/core/credentials/log.go new file mode 100644 index 0000000000..67b0c5da2f --- /dev/null +++ b/core/credentials/log.go @@ -0,0 +1,42 @@ +package credentials + +import ( + "context" + "log/slog" +) + +var credentialsLogger *slog.Logger + +// SetLogger sets the slog.Logger used by the credentials providers. +// Pass nil to disable logging (the default — the package is silent unless opted in). +// +// Example: +// +// credentials.SetLogger(slog.Default()) +func SetLogger(l *slog.Logger) { + credentialsLogger = l +} + +// debugContext emits a Debug-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func debugContext(ctx context.Context, msg string, args ...any) { + if credentialsLogger != nil { + credentialsLogger.DebugContext(ctx, msg, args...) + } +} + +// warnContext emits a Warn-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func warnContext(ctx context.Context, msg string, args ...any) { + if credentialsLogger != nil { + credentialsLogger.WarnContext(ctx, msg, args...) + } +} + +// errorContext emits an Error-level log to the registered logger, if any. +// args follows slog convention: alternating key, value pairs or slog.Attr values. +func errorContext(ctx context.Context, msg string, args ...any) { + if credentialsLogger != nil { + credentialsLogger.ErrorContext(ctx, msg, args...) + } +} diff --git a/core/credentials/resolve.go b/core/credentials/resolve.go new file mode 100644 index 0000000000..665a8a69f5 --- /dev/null +++ b/core/credentials/resolve.go @@ -0,0 +1,79 @@ +package credentials + +import ( + "os" + "sync" +) + +// credentialsFileSource reads the credentials file lazily and at most once, so that +// resolving several values (and several providers in a chain) does not read the file, or +// warn about its permissions, repeatedly. A file that cannot be read yields no values. +type credentialsFileSource struct { + path string + once sync.Once + creds *credentialsFile +} + +func newCredentialsFileSource(path string) *credentialsFileSource { + return &credentialsFileSource{path: path} +} + +// value returns the field selected by pick, or "" when the file is unavailable. +func (s *credentialsFileSource) value(pick func(*credentialsFile) string) string { + s.once.Do(func() { + if creds, err := readCredentialsFile(s.path); err == nil { + s.creds = creds + } + }) + if s.creds == nil { + return "" + } + return pick(s.creds) +} + +// fileSourceFor returns shared, or a new source for path when the provider is built on its own. +func fileSourceFor(shared *credentialsFileSource, path string) *credentialsFileSource { + if shared != nil { + return shared + } + return newCredentialsFileSource(path) +} + +// resolveValue returns the first non-empty value among, in order: explicit, the contents of +// the file at explicitPath, the environment variable envVar, the contents of the file named +// by envPathVar, and the fallbacks. Empty explicitPath and envPathVar are skipped. It returns +// "" when nothing is set. An error is returned only when a path that is set cannot be read. +func resolveValue(explicit, explicitPath, envVar, envPathVar string, fallbacks ...func() string) (string, error) { + if explicit != "" { + return explicit, nil + } + if explicitPath != "" { + data, err := os.ReadFile(explicitPath) + if err != nil { + return "", err + } + return string(data), nil + } + if value := os.Getenv(envVar); value != "" { + return value, nil + } + if envPathVar != "" { + if path := os.Getenv(envPathVar); path != "" { + data, err := os.ReadFile(path) //nolint:gosec // G703: the path is configuration chosen by the caller + if err != nil { + return "", err + } + return string(data), nil + } + } + for _, fallback := range fallbacks { + if value := fallback(); value != "" { + return value, nil + } + } + return "", nil +} + +func fromFile(s *credentialsFileSource, pick func(*credentialsFile) string) func() string { + return func() string { return s.value(pick) } +} diff --git a/core/credentials/service_account_key_provider.go b/core/credentials/service_account_key_provider.go new file mode 100644 index 0000000000..dd819ff233 --- /dev/null +++ b/core/credentials/service_account_key_provider.go @@ -0,0 +1,250 @@ +package credentials + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + // nolint:gosec // G101 False positive: This is a constant URL, not a credential + serviceAccountKeyDefaultTokenURL = "https://accounts.stackit.cloud/oauth/v2/token" + // nolint:gosec // G101 False positive: This is a constant protocol string, not a credential + serviceAccountJWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + serviceAccountKeyDefaultLeeway = time.Minute + serviceAccountKeyErrorPrefix = "service account key provider" +) + +// ServiceAccountKeyProviderConfig contains the configuration for ServiceAccountKeyProvider. +type ServiceAccountKeyProviderConfig struct { + // ServiceAccountKey is the service account key as a JSON string. + // If empty, ServiceAccountKeyPath is used, then the STACKIT_SERVICE_ACCOUNT_KEY + // environment variable, then STACKIT_SERVICE_ACCOUNT_KEY_PATH, then the credentials file. + ServiceAccountKey string + // ServiceAccountKeyPath is the path of a file with the service account key. + // It is used when ServiceAccountKey is empty. + ServiceAccountKeyPath string + // PrivateKey is the RSA private key as a PEM-encoded string. + // If empty, PrivateKeyPath is used, then the STACKIT_PRIVATE_KEY environment variable, + // then STACKIT_PRIVATE_KEY_PATH, then the private key embedded in the service account + // key, then the credentials file. + PrivateKey string + // PrivateKeyPath is the path of a file with the PEM-encoded RSA private key. + // It is used when PrivateKey is empty. + PrivateKeyPath string + // TokenURL overrides the token endpoint. If empty, the value from the service account key is used, + // falling back to the default STACKIT token endpoint. + TokenURL string + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client + // CredentialsFilePath overrides the credentials file location. If empty, the path from + // STACKIT_CREDENTIALS_PATH is used, falling back to ~/.stackit/credentials.json. + CredentialsFilePath string + // Optional scopes to request for the access token + Scopes []string + // Optional resources to request for the access token + Resources []string + + // credentialsFile is shared by NewDefaultProvider so the file is read once per chain. + credentialsFile *credentialsFileSource +} + +var _ TokenProvider = (*ServiceAccountKeyProvider)(nil) + +// ServiceAccountKey retrieves tokens using STACKIT service account key flow. +type ServiceAccountKeyProvider struct { + name string + httpClient *http.Client + tokenURL string + json *serviceAccountJSON + privateKey *rsa.PrivateKey + + tokenLeeway time.Duration + cache tokenCache + scopes []string + resources []string +} + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// NewServiceAccountKeyProvider creates a ServiceAccountKey provider. +// It resolves configuration from environment variables if not provided in the config. +func NewServiceAccountKeyProvider(cfg *ServiceAccountKeyProviderConfig) (*ServiceAccountKeyProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", serviceAccountKeyErrorPrefix) + } + file := fileSourceFor(cfg.credentialsFile, cfg.CredentialsFilePath) + + // Resolve service account key from config, env vars, or credentials file + serviceAccountKeyJSON, err := resolveValue(cfg.ServiceAccountKey, cfg.ServiceAccountKeyPath, envServiceAccountKey, envServiceAccountKeyPath, + fromFile(file, func(c *credentialsFile) string { return c.ServiceAccountKey })) + if err != nil { + return nil, fmt.Errorf("%s: read service account key from path: %w", serviceAccountKeyErrorPrefix, err) + } + if serviceAccountKeyJSON == "" { + return nil, fmt.Errorf("%s: service account key not provided and not found in environment variables", serviceAccountKeyErrorPrefix) + } + + // Parse service account key JSON + var serviceAccountKey = &serviceAccountJSON{} + if err := json.Unmarshal([]byte(serviceAccountKeyJSON), serviceAccountKey); err != nil { + return nil, fmt.Errorf("%s: parse service account key JSON: %w", serviceAccountKeyErrorPrefix, err) + } + + if serviceAccountKey.Credentials == nil { + return nil, fmt.Errorf("%s: service account key credentials cannot be empty", serviceAccountKeyErrorPrefix) + } + + // Resolve private key from config, env vars, embedded in service account key, or credentials file + privateKeyPEM, err := resolveValue(cfg.PrivateKey, cfg.PrivateKeyPath, envPrivateKey, envPrivateKeyPath, + func() string { + if serviceAccountKey.Credentials.PrivateKey != nil { + return *serviceAccountKey.Credentials.PrivateKey + } + return "" + }, + fromFile(file, func(c *credentialsFile) string { return c.PrivateKey })) + if err != nil { + return nil, fmt.Errorf("%s: read private key from path: %w", serviceAccountKeyErrorPrefix, err) + } + + if privateKeyPEM == "" { + return nil, fmt.Errorf("%s: private key cannot be empty", serviceAccountKeyErrorPrefix) + } + + // Parse RSA private key + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM)) + if err != nil { + return nil, fmt.Errorf("%s: parse private key from PEM: %w", serviceAccountKeyErrorPrefix, err) + } + + // Resolve token URL + tokenURL := cfg.TokenURL + if tokenURL == "" { + tokenURL = serviceAccountKey.Credentials.TokenEndpoint + } + if tokenURL == "" { + tokenURL = serviceAccountKeyDefaultTokenURL + } + + // Resolve HTTP client - use clean client to avoid deadlocks if the provided client already has auth roundtrippers + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) + + // Resolve token refresh leeway + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = serviceAccountKeyDefaultLeeway + } + + return &ServiceAccountKeyProvider{ + name: "ServiceAccountKeyProvider", + httpClient: httpClient, + tokenURL: tokenURL, + json: serviceAccountKey, + privateKey: privateKey, + tokenLeeway: leeway, + scopes: cfg.Scopes, + resources: cfg.Resources, + }, nil +} + +// Token returns a valid access token. +func (p *ServiceAccountKeyProvider) Token(ctx context.Context, opt TokenRequestOptions) (Token, error) { + scopes, resources := effectiveRequest(opt, p.scopes, p.resources) + return p.cache.get(ctx, requestKey(scopes, resources), p.tokenLeeway, func(ctx context.Context) (Token, error) { + fresh, err := p.requestToken(ctx, scopes, resources) + if err != nil { + return Token{}, err + } + debugContext(ctx, "credentials: authenticated", "provider", p.name) + return fresh, nil + }) +} + +func (p *ServiceAccountKeyProvider) requestToken(ctx context.Context, scopes, resources []string) (Token, error) { + assertion, err := p.generateSelfSignedJWT() + if err != nil { + return Token{}, err + } + + body := url.Values{} + body.Set("grant_type", serviceAccountJWTBearerGrantType) + body.Set("assertion", assertion) + setScopesAndResources(body, scopes, resources) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) + if err != nil { + return Token{}, fmt.Errorf("%s: create token request: %w", serviceAccountKeyErrorPrefix, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request access token: %w", serviceAccountKeyErrorPrefix, err) + } + defer func() { + _ = res.Body.Close() + }() + + if res.StatusCode != http.StatusOK { + bodyRaw, _ := io.ReadAll(res.Body) + errorContext(ctx, "credentials: token request failed", "status", res.StatusCode, "body", string(bodyRaw)) + return Token{}, fmt.Errorf("%s: token request failed with status %d: %s", serviceAccountKeyErrorPrefix, res.StatusCode, string(bodyRaw)) + } + + var tokenResponse oauthTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode token response: %w", serviceAccountKeyErrorPrefix, err) + } + if tokenResponse.AccessToken == "" { + return Token{}, fmt.Errorf("%s: token response did not include access token", serviceAccountKeyErrorPrefix) + } + + expiresOn, err := getTokenExpiration(tokenResponse.AccessToken, tokenResponse.ExpiresIn) + if err != nil { + return Token{}, fmt.Errorf("%s: %w", serviceAccountKeyErrorPrefix, err) + } + + refreshOn := expiresOn.Add(-p.tokenLeeway) + return Token{ + AccessToken: tokenResponse.AccessToken, + TokenType: tokenType(tokenResponse.TokenType), + ExpiresOn: expiresOn, + RefreshOn: refreshOn, + }, nil +} + +func (p *ServiceAccountKeyProvider) generateSelfSignedJWT() (string, error) { + claims := jwt.MapClaims{ + "iss": p.json.Credentials.Iss, + "sub": p.json.Credentials.Sub, + "jti": uuid.New(), + "aud": p.json.Credentials.Aud, + "iat": jwt.NewNumericDate(time.Now()), + "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS512, claims) + token.Header["kid"] = p.json.Credentials.Kid + + tokenString, err := token.SignedString(p.privateKey) + if err != nil { + return "", fmt.Errorf("%s: sign self-signed jwt: %w", serviceAccountKeyErrorPrefix, err) + } + return tokenString, nil +} diff --git a/core/credentials/service_account_key_provider_test.go b/core/credentials/service_account_key_provider_test.go new file mode 100644 index 0000000000..308472500f --- /dev/null +++ b/core/credentials/service_account_key_provider_test.go @@ -0,0 +1,654 @@ +package credentials + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func TestServiceAccountKeyToken(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var requests int64 + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" { + t.Fatalf("unexpected grant type: %s", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("assertion") == "" { + t.Fatalf("expected assertion") + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + // Create service account key as JSON + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +// TestServiceAccountKeyAssertion checks the self-signed JWT sent to the token endpoint: +// its signature, algorithm, key id and claims. +func TestServiceAccountKeyAssertion(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + var ( + mu sync.Mutex + assertions []string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + mu.Lock() + assertions = append(assertions, r.PostForm.Get("assertion")) + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": accessToken, "expires_in": 3600}) + })) + defer server.Close() + + const ( + issuer = "service-account@sa.stackit.cloud" + audience = "https://audience.example" + keyID = "the-key-id" + ) + subject := uuid.New() + saKeyJSON, err := json.Marshal(serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{Aud: audience, Iss: issuer, Kid: keyID, Sub: subject}, + }) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + // Different scopes force two token requests, so two assertions are sent. + for _, scope := range []string{"first", "second"} { + if _, err := provider.Token(context.Background(), TokenRequestOptions{Scopes: []string{scope}}); err != nil { + t.Fatalf("token: %v", err) + } + } + if len(assertions) != 2 { + t.Fatalf("expected 2 assertions, got %d", len(assertions)) + } + + before := time.Now().Add(-time.Minute) + var ids []string + for _, assertion := range assertions { + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(assertion, claims, func(*jwt.Token) (any, error) { + return &privateKey.PublicKey, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS512.Alg()})) + if err != nil { + t.Fatalf("assertion is not signed with the private key as RS512: %v", err) + } + if got := token.Header["kid"]; got != keyID { + t.Errorf("expected kid %q, got %v", keyID, got) + } + if claims.Issuer != issuer { + t.Errorf("expected iss %q, got %q", issuer, claims.Issuer) + } + if claims.Subject != subject.String() { + t.Errorf("expected sub %q, got %q", subject, claims.Subject) + } + // The audience comes from the key, not from the token URL the request is sent to. + if len(claims.Audience) != 1 || claims.Audience[0] != audience { + t.Errorf("expected aud [%q], got %v", audience, claims.Audience) + } + if claims.ID == "" { + t.Errorf("expected a jti") + } + if claims.IssuedAt == nil || claims.IssuedAt.Before(before) || claims.IssuedAt.After(time.Now().Add(time.Minute)) { + t.Errorf("iat is not close to now: %v", claims.IssuedAt) + } + if claims.ExpiresAt == nil || claims.IssuedAt == nil || claims.ExpiresAt.Sub(claims.IssuedAt.Time) != time.Hour { + t.Errorf("expected the assertion to live for one hour, got iat=%v exp=%v", claims.IssuedAt, claims.ExpiresAt) + } + ids = append(ids, claims.ID) + } + if ids[0] == ids[1] { + t.Errorf("each assertion needs its own jti, both were %q", ids[0]) + } +} + +func TestServiceAccountKeyRequiresKeyAndPrivateKey(t *testing.T) { + _, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestServiceAccountKeyWithScopes(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{"scope1", "scope2"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expected := "scope1 scope2" + if scopesReceived != expected { + t.Fatalf("expected scopes %q, got %q", expected, scopesReceived) + } +} + +func TestServiceAccountKeyWithResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Resources: []string{"resource1", "resource2", "resource3"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expected := []string{"resource1", "resource2", "resource3"} + if len(resourcesReceived) != len(expected) { + t.Fatalf("expected %d resources, got %d", len(expected), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != expected[i] { + t.Fatalf("expected resource %q at index %d, got %q", expected[i], i, res) + } + } +} + +func TestServiceAccountKeyWithScopesAndResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{"read", "write"}, + Resources: []string{"resourceA", "resourceB"}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + expectedScopes := "read write" + if scopesReceived != expectedScopes { + t.Fatalf("expected scopes %q, got %q", expectedScopes, scopesReceived) + } + + expectedResources := []string{"resourceA", "resourceB"} + if len(resourcesReceived) != len(expectedResources) { + t.Fatalf("expected %d resources, got %d", len(expectedResources), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != expectedResources[i] { + t.Fatalf("expected resource %q at index %d, got %q", expectedResources[i], i, res) + } + } +} + +func TestServiceAccountKeyWithEmptyScopesAndResources(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var scopesReceived string + var resourcesReceived []string + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: []string{}, + Resources: []string{}, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("token: %v", err) + } + + if scopesReceived != "" { + t.Fatalf("expected empty scopes, got %q", scopesReceived) + } + if len(resourcesReceived) != 0 { + t.Fatalf("expected no resources, got %d", len(resourcesReceived)) + } +} + +func TestServiceAccountKeyScopesAndResourcesOverride(t *testing.T) { + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + tests := []struct { + name string + configScopes []string + configResources []string + optionScopes []string + optionResources []string + expectedScopes string + expectedResources []string + }{ + { + name: "no config, no options", + configScopes: []string{}, + configResources: []string{}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "", + expectedResources: []string{}, + }, + { + name: "config scopes only", + configScopes: []string{"read", "write"}, + configResources: []string{}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "read write", + expectedResources: []string{}, + }, + { + name: "config resources only", + configScopes: []string{}, + configResources: []string{"res1", "res2"}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "", + expectedResources: []string{"res1", "res2"}, + }, + { + name: "option scopes override config", + configScopes: []string{"config-scope"}, + configResources: []string{}, + optionScopes: []string{"option-scope1", "option-scope2"}, + optionResources: nil, + expectedScopes: "option-scope1 option-scope2", + expectedResources: []string{}, + }, + { + name: "option resources override config", + configScopes: []string{}, + configResources: []string{"config-res"}, + optionScopes: nil, + optionResources: []string{"option-res1", "option-res2"}, + expectedScopes: "", + expectedResources: []string{"option-res1", "option-res2"}, + }, + { + name: "option scopes and resources override config", + configScopes: []string{"config-scope"}, + configResources: []string{"config-res"}, + optionScopes: []string{"opt-scope1", "opt-scope2"}, + optionResources: []string{"opt-res1", "opt-res2"}, + expectedScopes: "opt-scope1 opt-scope2", + expectedResources: []string{"opt-res1", "opt-res2"}, + }, + { + name: "empty option scopes use config", + configScopes: []string{"config-scope"}, + configResources: []string{}, + optionScopes: []string{}, + optionResources: nil, + expectedScopes: "config-scope", + expectedResources: []string{}, + }, + { + name: "empty option resources use config", + configScopes: []string{}, + configResources: []string{"config-res"}, + optionScopes: nil, + optionResources: []string{}, + expectedScopes: "", + expectedResources: []string{"config-res"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var scopesReceived string + var resourcesReceived []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + saKey := serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, + Iss: "service-account@sa.stackit.cloud", + Kid: "kid", + Sub: uuid.New(), + }, + } + saKeyJSON, err := json.Marshal(saKey) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: tt.configScopes, + Resources: tt.configResources, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{ + Scopes: tt.optionScopes, + Resources: tt.optionResources, + }) + if err != nil { + t.Fatalf("token: %v", err) + } + + if scopesReceived != tt.expectedScopes { + t.Fatalf("expected scopes %q, got %q", tt.expectedScopes, scopesReceived) + } + if len(resourcesReceived) != len(tt.expectedResources) { + t.Fatalf("expected %d resources, got %d", len(tt.expectedResources), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != tt.expectedResources[i] { + t.Fatalf("expected resource %q at index %d, got %q", tt.expectedResources[i], i, res) + } + } + }) + } +} + +func generateRSAPrivateKeyPEM() ([]byte, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), nil +} diff --git a/core/credentials/service_account_key_resolution_test.go b/core/credentials/service_account_key_resolution_test.go new file mode 100644 index 0000000000..0bc30ba618 --- /dev/null +++ b/core/credentials/service_account_key_resolution_test.go @@ -0,0 +1,228 @@ +package credentials + +import ( + "context" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// keyResolutionServer answers token requests and remembers the assertion it received. +func keyResolutionServer(t *testing.T) (url string, assertion func() string) { + t.Helper() + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("test")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + var received string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + received = r.PostForm.Get("assertion") + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": accessToken, "expires_in": 3600}) + })) + t.Cleanup(server.Close) + return server.URL, func() string { return received } +} + +func serviceAccountKeyJSONFor(t *testing.T, issuer string) string { + t.Helper() + content, err := json.Marshal(serviceAccountJSON{ + Credentials: &serviceAccountKeyCredentials{Aud: "aud", Iss: issuer, Kid: "kid", Sub: uuid.New()}, + }) + if err != nil { + t.Fatalf("marshal service account key: %v", err) + } + return string(content) +} + +func writeTestFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + return path +} + +func newRSAKey(t *testing.T) (pem string, key *rsa.PrivateKey) { + t.Helper() + raw, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + key, err = jwt.ParseRSAPrivateKeyFromPEM(raw) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + return string(raw), key +} + +func requestToken(t *testing.T, cfg *ServiceAccountKeyProviderConfig, tokenURL string) { + t.Helper() + cfg.TokenURL = tokenURL + provider, err := NewServiceAccountKeyProvider(cfg) + if err != nil { + t.Fatalf("new provider: %v", err) + } + if _, err := provider.Token(context.Background(), TokenRequestOptions{}); err != nil { + t.Fatalf("token: %v", err) + } +} + +// TestServiceAccountKeyResolutionOrder pins where the service account key is taken from: +// the config, then the path in the config, then the environment variable, then the path +// in the environment, so that anything explicit in code wins over the environment. +func TestServiceAccountKeyResolutionOrder(t *testing.T) { + privateKeyPEM, _ := newRSAKey(t) + tokenURL, assertion := keyResolutionServer(t) + + tests := []struct { + name string + config, configPath, environment, envPath bool + wantIssuer string + }{ + {name: "config content wins over everything", config: true, configPath: true, environment: true, envPath: true, wantIssuer: "config"}, + {name: "config path wins over the environment", configPath: true, environment: true, envPath: true, wantIssuer: "config-path"}, + {name: "environment content wins over the environment path", environment: true, envPath: true, wantIssuer: "environment"}, + {name: "environment path alone", envPath: true, wantIssuer: "environment-path"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clearCredentialEnv(t) + cfg := &ServiceAccountKeyProviderConfig{PrivateKey: privateKeyPEM} + if test.config { + cfg.ServiceAccountKey = serviceAccountKeyJSONFor(t, "config") + } + if test.configPath { + cfg.ServiceAccountKeyPath = writeTestFile(t, serviceAccountKeyJSONFor(t, "config-path")) + } + if test.environment { + t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY", serviceAccountKeyJSONFor(t, "environment")) + } + if test.envPath { + t.Setenv("STACKIT_SERVICE_ACCOUNT_KEY_PATH", writeTestFile(t, serviceAccountKeyJSONFor(t, "environment-path"))) + } + + requestToken(t, cfg, tokenURL) + + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(assertion(), claims); err != nil { + t.Fatalf("parse assertion: %v", err) + } + if claims["iss"] != test.wantIssuer { + t.Fatalf("expected the key from %q, got %v", test.wantIssuer, claims["iss"]) + } + }) + } +} + +// TestServiceAccountKeyPrivateKeyResolutionOrder does the same for the private key, which +// has one more source: the one embedded in the service account key. +func TestServiceAccountKeyPrivateKeyResolutionOrder(t *testing.T) { + tokenURL, assertion := keyResolutionServer(t) + + // Case i creates a distinct key for source i and for every source below it, so the key + // that must win is the one of source i. + order := []string{"config", "configPath", "environment", "envPath", "embedded", "file"} + for i, want := range order { + t.Run("wins: "+want, func(t *testing.T) { + clearCredentialEnv(t) + pems := map[string]string{} + rsaKeys := map[string]*rsa.PrivateKey{} + for _, source := range order[i:] { // only this source and the ones below it exist + pems[source], rsaKeys[source] = newRSAKey(t) + } + + cfg := &ServiceAccountKeyProviderConfig{} + keyJSON := serviceAccountKeyJSONFor(t, "iss") + if pem, ok := pems["embedded"]; ok { + var key serviceAccountJSON + if err := json.Unmarshal([]byte(keyJSON), &key); err != nil { + t.Fatal(err) + } + key.Credentials.PrivateKey = &pem + content, _ := json.Marshal(key) + keyJSON = string(content) + } + cfg.ServiceAccountKey = keyJSON + if pem, ok := pems["config"]; ok { + cfg.PrivateKey = pem + } + if pem, ok := pems["configPath"]; ok { + cfg.PrivateKeyPath = writeTestFile(t, pem) + } + if pem, ok := pems["environment"]; ok { + t.Setenv("STACKIT_PRIVATE_KEY", pem) + } + if pem, ok := pems["envPath"]; ok { + t.Setenv("STACKIT_PRIVATE_KEY_PATH", writeTestFile(t, pem)) + } + if pem, ok := pems["file"]; ok { + credentialsPath := writeTestFile(t, `{"STACKIT_PRIVATE_KEY": `+strings.TrimSpace(mustJSONString(t, pem))+`}`) + cfg.CredentialsFilePath = credentialsPath + } + + requestToken(t, cfg, tokenURL) + + _, err := jwt.Parse(assertion(), func(*jwt.Token) (any, error) { + return &rsaKeys[want].PublicKey, nil + }, jwt.WithValidMethods([]string{"RS512"})) + if err != nil { + t.Fatalf("the assertion was not signed with the private key from %q: %v", want, err) + } + }) + } +} + +func mustJSONString(t *testing.T, s string) string { + t.Helper() + content, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func TestServiceAccountKeyUnreadablePaths(t *testing.T) { + privateKeyPEM, _ := newRSAKey(t) + missing := filepath.Join(t.TempDir(), "missing") + + t.Run("service account key path", func(t *testing.T) { + clearCredentialEnv(t) + _, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ServiceAccountKeyPath: missing, PrivateKey: privateKeyPEM}) + if err == nil || !strings.Contains(err.Error(), "read service account key from path") { + t.Fatalf("expected an error reading the service account key path, got %v", err) + } + }) + t.Run("private key path", func(t *testing.T) { + clearCredentialEnv(t) + _, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ServiceAccountKey: serviceAccountKeyJSONFor(t, "iss"), PrivateKeyPath: missing}) + if err == nil || !strings.Contains(err.Error(), "read private key from path") { + t.Fatalf("expected an error reading the private key path, got %v", err) + } + }) + t.Run("content in the config makes the path irrelevant", func(t *testing.T) { + clearCredentialEnv(t) + _, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: serviceAccountKeyJSONFor(t, "iss"), ServiceAccountKeyPath: missing, + PrivateKey: privateKeyPEM, PrivateKeyPath: missing, + }) + if err != nil { + t.Fatalf("expected the unused paths to be ignored, got %v", err) + } + }) +} diff --git a/core/credentials/static_token_provider.go b/core/credentials/static_token_provider.go new file mode 100644 index 0000000000..74c5f47bdf --- /dev/null +++ b/core/credentials/static_token_provider.go @@ -0,0 +1,65 @@ +package credentials + +import ( + "context" + "fmt" +) + +const staticTokenErrorPrefix = "static token provider" + +var _ TokenProvider = (*StaticTokenProvider)(nil) + +// StaticTokenProviderConfig contains configuration for StaticTokenProvider. +type StaticTokenProviderConfig struct { + // Token is the static access token. If empty, it is resolved from the + // STACKIT_SERVICE_ACCOUNT_TOKEN env var and then from the credentials file. + Token string + // CredentialsFilePath overrides the credentials file location. If empty, the path from + // STACKIT_CREDENTIALS_PATH is used, falling back to ~/.stackit/credentials.json. + CredentialsFilePath string + + // credentialsFile is shared by NewDefaultProvider so the file is read once per chain. + credentialsFile *credentialsFileSource +} + +// StaticTokenProvider provides a static access token. +type StaticTokenProvider struct { + name string + token Token +} + +// NewStaticTokenProvider creates a StaticTokenProvider, resolving the token from config or environment. +func NewStaticTokenProvider(cfg *StaticTokenProviderConfig) (*StaticTokenProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", staticTokenErrorPrefix) + } + file := fileSourceFor(cfg.credentialsFile, cfg.CredentialsFilePath) + token, _ := resolveValue(cfg.Token, "", envServiceAccountToken, "", + fromFile(file, func(c *credentialsFile) string { return c.ServiceAccountToken })) + if token == "" { + return nil, fmt.Errorf("static token provider: %s not set and no token provided in config", envServiceAccountToken) + } + + expiresOn, err := getTokenExpiration(token, 0) + if err != nil { + return nil, fmt.Errorf("static token expiration invalid: %w", err) + } + + accessToken := Token{ + AccessToken: token, + // A pre-issued token carries no type of its own. + TokenType: defaultTokenType, + ExpiresOn: expiresOn, + RefreshOn: expiresOn, + } + + return &StaticTokenProvider{ + name: "StaticTokenProvider", + token: accessToken, + }, nil +} + +// Token returns the static access token, parsing its expiration from the JWT claims. +func (p *StaticTokenProvider) Token(_ context.Context, _ TokenRequestOptions) (Token, error) { + return p.token, nil +} diff --git a/core/credentials/static_token_provider_test.go b/core/credentials/static_token_provider_test.go new file mode 100644 index 0000000000..3350dfa1d8 --- /dev/null +++ b/core/credentials/static_token_provider_test.go @@ -0,0 +1,37 @@ +package credentials + +import ( + "context" + "testing" +) + +func TestStaticTokenProvider(t *testing.T) { + //nolint:gosec // G101 False positive: This is a test JWT token, not a credential + accessToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.test" + provider, err := NewStaticTokenProvider(&StaticTokenProviderConfig{Token: accessToken}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + token, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("expected no error: %v", err) + } + + if token.AccessToken != accessToken { + t.Fatalf("expected %s, got %s", accessToken, token.AccessToken) + } + + if token.ExpiresOn.IsZero() { + t.Fatalf("expected non-zero expiration") + } +} + +func TestStaticTokenProviderEmpty(t *testing.T) { + isolateCredentialsFile(t) + + _, err := NewStaticTokenProvider(&StaticTokenProviderConfig{Token: ""}) + if err == nil { + t.Fatalf("expected error for empty token") + } +} diff --git a/core/credentials/token.go b/core/credentials/token.go new file mode 100644 index 0000000000..a57b1a7f45 --- /dev/null +++ b/core/credentials/token.go @@ -0,0 +1,49 @@ +package credentials + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Token is a normalized access token response. +type Token struct { + AccessToken string + // TokenType is the authorization scheme the token is presented with, taken from the + // token_type of the OAuth2 response. It is "Bearer" for every flow STACKIT issues + // today, but callers building their own Authorization header must use this rather + // than assuming. + // Providers whose source does not report a type default to "Bearer". + TokenType string + ExpiresOn time.Time + RefreshOn time.Time +} + +// getTokenExpiration extracts the expiration time from a JWT token. +// If expiresIn is provided (> 0), it's used to calculate expiration as Now + expiresIn seconds. +// Otherwise, the exp claim is parsed from the token. +func getTokenExpiration(accessToken string, expiresIn int) (time.Time, error) { + if expiresIn > 0 { + return time.Now().Add(time.Duration(expiresIn) * time.Second), nil + } + + parsed, _, err := jwt.NewParser().ParseUnverified(accessToken, &jwt.RegisteredClaims{}) + if err != nil { + return time.Time{}, fmt.Errorf("parse access token: %w", err) + } + exp, err := parsed.Claims.GetExpirationTime() + if err != nil { + return time.Time{}, fmt.Errorf("get token expiration: %w", err) + } + return exp.Time, nil +} + +// tokenType normalizes the token_type of an OAuth2 response. Sources that do not report +// one are treated as defaultTokenType, which is what every STACKIT flow issues today. +func tokenType(reported string) string { + if reported == "" { + return defaultTokenType + } + return reported +} diff --git a/core/credentials/token_cache.go b/core/credentials/token_cache.go new file mode 100644 index 0000000000..1e4359898a --- /dev/null +++ b/core/credentials/token_cache.go @@ -0,0 +1,160 @@ +package credentials + +import ( + "context" + "errors" + "net/url" + "slices" + "strings" + "sync" + "time" +) + +// tokenCache stores one token per distinct (scopes, resources) request and collapses +// concurrent refreshes of the same request into a single fetch (singleflight). +// The zero value is ready to use. +// +// Concurrent refreshes are collapsed with the small singleflight below instead of +// golang.org/x/sync/singleflight, to keep the dependencies of core to a minimum: every +// service module inherits them. +// +// Reads vastly outnumber writes (every API call asks for a token, while a refresh only +// happens when the token is about to expire), so the lock is a RWMutex and the hit path +// takes only the read lock. +type tokenCache struct { + mu sync.RWMutex + entries map[string]Token + inflight map[string]*tokenCall +} + +type tokenCall struct { + done chan struct{} + token Token + err error +} + +// get returns the cached token for key while it is fresh, and otherwise calls fetch. +// Callers asking for the same key while a fetch is running wait for it instead of +// issuing their own request. +func (c *tokenCache) get(ctx context.Context, key string, leeway time.Duration, fetch func(context.Context) (Token, error)) (Token, error) { + for { + // Fast path: a cache hit only needs to read, so concurrent callers do not serialize. + c.mu.RLock() + token, ok := c.fresh(key, leeway) + c.mu.RUnlock() + if ok { + return token, nil + } + + c.mu.Lock() + // Another caller may have stored a token between releasing the read lock and getting + // the write lock. + if token, ok := c.fresh(key, leeway); ok { + c.mu.Unlock() + return token, nil + } + + call, running := c.inflight[key] + if !running { + call = &tokenCall{done: make(chan struct{})} + if c.inflight == nil { + c.inflight = make(map[string]*tokenCall) + } + c.inflight[key] = call + c.mu.Unlock() + return c.lead(ctx, key, call, fetch) + } + c.mu.Unlock() + + select { + case <-call.done: + case <-ctx.Done(): + return Token{}, ctx.Err() + } + if call.err == nil { + return call.token, nil + } + // The leader failed because its own context ended, not because the token could not + // be obtained. This caller's context is still alive, so try again. + if ctx.Err() == nil && (errors.Is(call.err, context.Canceled) || errors.Is(call.err, context.DeadlineExceeded)) { + continue + } + return Token{}, call.err + } +} + +// fresh returns the cached token for key if it is still worth handing out. Callers hold c.mu. +func (c *tokenCache) fresh(key string, leeway time.Duration) (Token, bool) { + token, ok := c.entries[key] + if !ok || token.AccessToken == "" || !token.RefreshOn.After(time.Now().Add(leeway)) { + return Token{}, false + } + return token, true +} + +func (c *tokenCache) lead(ctx context.Context, key string, call *tokenCall, fetch func(context.Context) (Token, error)) (Token, error) { + // Release the waiters and the inflight slot even if fetch panics. + call.err = errors.New("token fetch did not complete") + defer func() { + c.mu.Lock() + delete(c.inflight, key) + if call.err == nil { + c.store(key, call.token) + } + c.mu.Unlock() + close(call.done) + }() + + call.token, call.err = fetch(ctx) + return call.token, call.err +} + +// store saves the token and drops entries that have expired, so a caller cycling through +// many scope combinations does not grow the cache without bound. Callers hold c.mu. +func (c *tokenCache) store(key string, token Token) { //nolint:gocritic // hugeParam: the token is kept by value, a pointer would only add a nil case + if c.entries == nil { + c.entries = make(map[string]Token) + } + now := time.Now() + for k, t := range c.entries { + if !t.ExpiresOn.IsZero() && t.ExpiresOn.Before(now) { + delete(c.entries, k) + } + } + c.entries[key] = token +} + +// effectiveRequest returns the scopes and resources to request: those of the call when +// given, otherwise the provider defaults. +func effectiveRequest(opt TokenRequestOptions, defaultScopes, defaultResources []string) (scopes, resources []string) { + scopes, resources = defaultScopes, defaultResources + if len(opt.Scopes) > 0 { + scopes = opt.Scopes + } + if len(opt.Resources) > 0 { + resources = opt.Resources + } + return scopes, resources +} + +// requestKey identifies a token request. Order and duplicates do not matter, as they do +// not change what the token is issued for. +func requestKey(scopes, resources []string) string { + return strings.Join(normalized(scopes), " ") + "\x00" + strings.Join(normalized(resources), " ") +} + +func normalized(values []string) []string { + out := slices.Clone(values) + slices.Sort(out) + return slices.Compact(out) +} + +// setScopesAndResources adds the scope and resource parameters of a token request. +func setScopesAndResources(body url.Values, scopes, resources []string) { + if len(scopes) > 0 { + body.Set("scope", strings.Join(scopes, " ")) + } + for _, resource := range resources { + body.Add("resource", resource) + } +} diff --git a/core/credentials/token_cache_test.go b/core/credentials/token_cache_test.go new file mode 100644 index 0000000000..431f65d9a9 --- /dev/null +++ b/core/credentials/token_cache_test.go @@ -0,0 +1,328 @@ +package credentials + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func newCacheTestProvider(t *testing.T, scopes, resources []string) (*ServiceAccountKeyProvider, *atomic.Int32) { + t.Helper() + privateKeyPEM, err := generateRSAPrivateKeyPEM() + if err != nil { + t.Fatalf("generate private key: %v", err) + } + + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + time.Sleep(20 * time.Millisecond) // give concurrent callers time to pile up + // The token records what it was issued for, so a test can check it got the right one. + // As in RFC 8707, every requested resource becomes an element of the audience. + claims := jwt.MapClaims{ + "exp": time.Now().Add(time.Hour).Unix(), + "scope": r.PostForm.Get("scope"), + } + if resources := r.PostForm["resource"]; len(resources) > 0 { + claims["aud"] = resources + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test")) + if err != nil { + t.Errorf("sign: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": accessToken, "expires_in": 3600}) + })) + t.Cleanup(server.Close) + + saKeyJSON, err := json.Marshal(serviceAccountJSON{Credentials: &serviceAccountKeyCredentials{ + Aud: server.URL, Iss: "sa@sa.stackit.cloud", Kid: "kid", Sub: uuid.New(), + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + provider, err := NewServiceAccountKeyProvider(&ServiceAccountKeyProviderConfig{ + ServiceAccountKey: string(saKeyJSON), + PrivateKey: string(privateKeyPEM), + TokenURL: server.URL, + HTTPClient: server.Client(), + Scopes: scopes, + Resources: resources, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + return provider, &requests +} + +// issuedFor returns the scope and the audience (the requested resources) the test server +// issued the token for. +func issuedFor(t *testing.T, accessToken string) (scope string, resources []string) { + t.Helper() + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(accessToken, claims); err != nil { + t.Fatalf("parse token: %v", err) + } + scope, _ = claims["scope"].(string) + audience, err := claims.GetAudience() + if err != nil { + t.Fatalf("read audience: %v", err) + } + return scope, audience +} + +func requireIssuedFor(t *testing.T, accessToken, wantScope string, wantResources ...string) { + t.Helper() + scope, resources := issuedFor(t, accessToken) + if scope != wantScope || !slices.Equal(resources, wantResources) { + t.Fatalf("token issued for scope %q and resources %v, want scope %q and resources %v", scope, resources, wantScope, wantResources) + } +} + +func TestProviderCachesOneTokenPerScopesAndResources(t *testing.T) { + provider, requests := newCacheTestProvider(t, nil, nil) + ctx := context.Background() + + get := func(opt TokenRequestOptions) string { + t.Helper() + token, err := provider.Token(ctx, opt) + if err != nil { + t.Fatalf("token: %v", err) + } + return token.AccessToken + } + + // Each combination gets a token issued for exactly what was asked... + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"read"}}), "read") + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"write"}}), "write") + requireIssuedFor(t, get(TokenRequestOptions{Resources: []string{"r1"}}), "", "r1") + if got := requests.Load(); got != 3 { + t.Fatalf("expected 3 token requests, got %d", got) + } + + // ...and is cached independently: repeating the requests neither hits the server nor + // returns the token of another combination. + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"read"}}), "read") + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"write"}}), "write") + requireIssuedFor(t, get(TokenRequestOptions{Resources: []string{"r1"}}), "", "r1") + if got := requests.Load(); got != 3 { + t.Fatalf("expected repeated requests to be served from the cache, got %d token requests", got) + } + + // The same scopes with other resources are a different request. + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"read"}, Resources: []string{"r1"}}), "read", "r1") + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"read"}}), "read") + if got := requests.Load(); got != 4 { + t.Fatalf("expected 4 token requests, got %d", got) + } + + // Order and duplicates do not matter: the second call reuses the first token. + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"a", "b"}}), "a b") + requireIssuedFor(t, get(TokenRequestOptions{Scopes: []string{"b", "a", "a"}}), "a b") + if got := requests.Load(); got != 5 { + t.Fatalf("expected 5 token requests, got %d", got) + } +} + +func TestEffectiveRequest(t *testing.T) { + defaultScopes := []string{"default"} + defaultResources := []string{"default-resource"} + + tests := []struct { + name string + opt TokenRequestOptions + wantScopes []string + wantResources []string + }{ + {name: "no options use the defaults", wantScopes: defaultScopes, wantResources: defaultResources}, + {name: "empty slices use the defaults", opt: TokenRequestOptions{Scopes: []string{}, Resources: []string{}}, wantScopes: defaultScopes, wantResources: defaultResources}, + {name: "scopes override and keep the default resources", opt: TokenRequestOptions{Scopes: []string{"other"}}, wantScopes: []string{"other"}, wantResources: defaultResources}, + {name: "resources override and keep the default scopes", opt: TokenRequestOptions{Resources: []string{"other-resource"}}, wantScopes: defaultScopes, wantResources: []string{"other-resource"}}, + {name: "both override", opt: TokenRequestOptions{Scopes: []string{"a", "b"}, Resources: []string{"r1", "r2"}}, wantScopes: []string{"a", "b"}, wantResources: []string{"r1", "r2"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scopes, resources := effectiveRequest(test.opt, defaultScopes, defaultResources) + if !slices.Equal(scopes, test.wantScopes) || !slices.Equal(resources, test.wantResources) { + t.Fatalf("got scopes %v and resources %v, want scopes %v and resources %v", scopes, resources, test.wantScopes, test.wantResources) + } + }) + } + + t.Run("nothing requested without defaults", func(t *testing.T) { + scopes, resources := effectiveRequest(TokenRequestOptions{}, nil, nil) + if len(scopes) != 0 || len(resources) != 0 { + t.Fatalf("expected nothing requested, got scopes %v and resources %v", scopes, resources) + } + }) +} + +func TestRequestKey(t *testing.T) { + same := []struct { + name string + a, b [2][]string // scopes, resources + }{ + {name: "order does not matter", a: [2][]string{{"a", "b"}, nil}, b: [2][]string{{"b", "a"}, nil}}, + {name: "duplicates do not matter", a: [2][]string{{"a", "b"}, nil}, b: [2][]string{{"b", "a", "a"}, nil}}, + {name: "resources order and duplicates do not matter", a: [2][]string{nil, {"r1", "r2"}}, b: [2][]string{nil, {"r2", "r1", "r2"}}}, + {name: "scopes are sent space separated, so these are one request", a: [2][]string{{"a b"}, nil}, b: [2][]string{{"a", "b"}, nil}}, + {name: "nil and empty are the same", a: [2][]string{nil, nil}, b: [2][]string{{}, {}}}, + } + for _, test := range same { + t.Run("same: "+test.name, func(t *testing.T) { + if a, b := requestKey(test.a[0], test.a[1]), requestKey(test.b[0], test.b[1]); a != b { + t.Fatalf("expected the same key, got %q and %q", a, b) + } + }) + } + + different := []struct { + name string + a, b [2][]string + }{ + {name: "other scopes", a: [2][]string{{"read"}, nil}, b: [2][]string{{"write"}, nil}}, + {name: "other resources", a: [2][]string{{"read"}, {"r1"}}, b: [2][]string{{"read"}, {"r2"}}}, + {name: "same scopes, with and without resources", a: [2][]string{{"read"}, nil}, b: [2][]string{{"read"}, {"r1"}}}, + {name: "the boundary between scopes and resources", a: [2][]string{{"a"}, {"b", "c"}}, b: [2][]string{{"a", "b"}, {"c"}}}, + {name: "a value as scope or as resource", a: [2][]string{{"x"}, nil}, b: [2][]string{nil, {"x"}}}, + } + for _, test := range different { + t.Run("different: "+test.name, func(t *testing.T) { + if a, b := requestKey(test.a[0], test.a[1]), requestKey(test.b[0], test.b[1]); a == b { + t.Fatalf("expected different keys, both were %q", a) + } + }) + } +} + +func TestProviderCollapsesConcurrentRefreshes(t *testing.T) { + provider, requests := newCacheTestProvider(t, nil, nil) + + const callers = 20 + var wg sync.WaitGroup + tokens := make([]string, callers) + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + token, err := provider.Token(context.Background(), TokenRequestOptions{Scopes: []string{"s"}}) + if err != nil { + t.Errorf("token: %v", err) + } + tokens[i] = token.AccessToken + }() + } + wg.Wait() + + if got := requests.Load(); got != 1 { + t.Fatalf("expected a single token request for concurrent callers, got %d", got) + } + for _, token := range tokens { + requireIssuedFor(t, token, "s") + } +} + +func TestTokenCacheWaiterRetriesWhenLeaderContextIsCancelled(t *testing.T) { + var cache tokenCache + leaderStarted := make(chan struct{}) + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + + leaderDone := make(chan error, 1) + go func() { + _, err := cache.get(leaderCtx, "k", 0, func(ctx context.Context) (Token, error) { + close(leaderStarted) + <-ctx.Done() + return Token{}, ctx.Err() + }) + leaderDone <- err + }() + <-leaderStarted + + waiterDone := make(chan Token, 1) + go func() { + token, err := cache.get(context.Background(), "k", 0, func(context.Context) (Token, error) { + return Token{AccessToken: "ok", RefreshOn: time.Now().Add(time.Hour)}, nil + }) + if err != nil { + t.Errorf("waiter: %v", err) + } + waiterDone <- token + }() + time.Sleep(20 * time.Millisecond) // let the waiter attach to the leader's call + cancelLeader() + + if err := <-leaderDone; !errors.Is(err, context.Canceled) { + t.Fatalf("leader: expected context.Canceled, got %v", err) + } + if token := <-waiterDone; token.AccessToken != "ok" { + t.Fatalf("waiter should have fetched its own token, got %q", token.AccessToken) + } +} + +func TestTokenCacheDoesNotCacheErrors(t *testing.T) { + var cache tokenCache + var calls atomic.Int32 + fetch := func(context.Context) (Token, error) { + if calls.Add(1) == 1 { + return Token{}, errors.New("boom") + } + return Token{AccessToken: "ok", RefreshOn: time.Now().Add(time.Hour)}, nil + } + if _, err := cache.get(context.Background(), "k", 0, fetch); err == nil { + t.Fatal("expected error") + } + if token, err := cache.get(context.Background(), "k", 0, fetch); err != nil || token.AccessToken != "ok" { + t.Fatalf("expected recovery after error, got %v %v", token, err) + } +} + +func TestResolveValueOrder(t *testing.T) { + t.Setenv("TEST_RESOLVE_VALUE", "") + t.Setenv("TEST_RESOLVE_PATH", "") + fallback := func(v string) func() string { return func() string { return v } } + + if got, _ := resolveValue("explicit", "", "TEST_RESOLVE_VALUE", "TEST_RESOLVE_PATH", fallback("f")); got != "explicit" { + t.Fatalf("explicit should win, got %q", got) + } + t.Setenv("TEST_RESOLVE_VALUE", "env") + if got, _ := resolveValue("", "", "TEST_RESOLVE_VALUE", "TEST_RESOLVE_PATH", fallback("f")); got != "env" { + t.Fatalf("env should beat fallbacks, got %q", got) + } + t.Setenv("TEST_RESOLVE_VALUE", "") + if got, _ := resolveValue("", "", "TEST_RESOLVE_VALUE", "TEST_RESOLVE_PATH", fallback(""), fallback("second")); got != "second" { + t.Fatalf("expected first non-empty fallback, got %q", got) + } + t.Setenv("TEST_RESOLVE_PATH", "/nonexistent/file") + if _, err := resolveValue("", "", "TEST_RESOLVE_VALUE", "TEST_RESOLVE_PATH", fallback("f")); err == nil { + t.Fatal("expected an error for an unreadable path") + } +} + +func BenchmarkTokenCacheHit(b *testing.B) { + var c tokenCache + fetch := func(context.Context) (Token, error) { + return Token{AccessToken: "t", RefreshOn: time.Now().Add(time.Hour), ExpiresOn: time.Now().Add(2 * time.Hour)}, nil + } + ctx := context.Background() + _, _ = c.get(ctx, "k", time.Minute, fetch) + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := c.get(ctx, "k", time.Minute, fetch); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/core/credentials/token_provider.go b/core/credentials/token_provider.go new file mode 100644 index 0000000000..784b4fdff4 --- /dev/null +++ b/core/credentials/token_provider.go @@ -0,0 +1,23 @@ +package credentials + +import "context" + +// TokenProvider exposes the minimal contract for retrieving an access token. +// +// Providers cache one token per distinct combination of scopes and resources and refresh +// each independently; concurrent requests for the same combination share a single refresh. +type TokenProvider interface { + Token(ctx context.Context, options TokenRequestOptions) (Token, error) +} + +// TokenRequestOptions carries optional token request hints for providers. +// +// Providers that do not issue tokens per scope or resource ignore them. +type TokenRequestOptions struct { + // Scopes is a list of OAuth2 scopes to request. + // This overrides any default scopes configured in the provider. + Scopes []string + // Resources is a list of resource identifiers to request access for. + // This overrides any default resources configured in the provider. + Resources []string +} diff --git a/core/credentials/types.go b/core/credentials/types.go new file mode 100644 index 0000000000..6f8304ede7 --- /dev/null +++ b/core/credentials/types.go @@ -0,0 +1,30 @@ +package credentials + +import ( + "time" + + "github.com/google/uuid" +) + +// serviceAccountJSON is the API response when creating a STACKIT service account key. +type serviceAccountJSON struct { + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + Credentials *serviceAccountKeyCredentials `json:"credentials"` + ID uuid.UUID `json:"id"` + KeyAlgorithm string `json:"keyAlgorithm"` + KeyOrigin string `json:"keyOrigin"` + KeyType string `json:"keyType"` + PublicKey string `json:"publicKey"` + ValidUntil *time.Time `json:"validUntil,omitempty"` +} + +// serviceAccountKeyCredentials contains the credential fields embedded in a serviceAccountJSON. +type serviceAccountKeyCredentials struct { + Aud string `json:"aud"` + Iss string `json:"iss"` + Kid string `json:"kid"` + PrivateKey *string `json:"privateKey,omitempty"` + Sub uuid.UUID `json:"sub"` + TokenEndpoint string `json:"tokenEndpoint"` +} diff --git a/core/credentials/workload_identity_federation_provider.go b/core/credentials/workload_identity_federation_provider.go new file mode 100644 index 0000000000..910af649e1 --- /dev/null +++ b/core/credentials/workload_identity_federation_provider.go @@ -0,0 +1,172 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" + "github.com/stackitcloud/stackit-sdk-go/core/utils" +) + +const ( + workloadIdentityClientIDEnv = envServiceAccountEmail + workloadIdentityDefaultLeeway = time.Minute + workloadIdentityErrorPrefix = "workload identity federation provider" +) + +// WorkloadIdentityFederationProviderConfig contains the configuration for WorkloadIdentityFederationProvider. +type WorkloadIdentityFederationProviderConfig struct { + // TokenURL overrides the token endpoint. If empty, STACKIT_IDP_TOKEN_ENDPOINT env var is used, + // falling back to the default STACKIT WIF token endpoint. + TokenURL string + // ClientID is the service account email. If empty, STACKIT_SERVICE_ACCOUNT_EMAIL env var is used. + ClientID string + // FederatedTokenFunction provides the OIDC assertion token. If nil, the default filesystem reader is used. + FederatedTokenFunction oidcadapters.OIDCTokenFunc + // TokenRefreshLeeway controls how early before expiration the token is refreshed. + // If zero, defaults to 1 minute. + TokenRefreshLeeway time.Duration + // HTTPClient is used for token requests. If nil, a default client with a 1-minute timeout is used. + HTTPClient *http.Client + // Optional scopes to request for the access token + Scopes []string + // Optional resources to request for the access token + Resources []string +} + +var _ TokenProvider = (*WorkloadIdentityFederationProvider)(nil) + +// WorkloadIdentityFederation retrieves tokens using WIF flow. +type WorkloadIdentityFederationProvider struct { + name string + httpClient *http.Client + tokenURL string + clientID string + federatedTokenFunction oidcadapters.OIDCTokenFunc + tokenLeeway time.Duration + cache tokenCache + scopes []string + resources []string +} + +type workloadIdentityTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// NewWorkloadIdentityFederationProvider creates a WorkloadIdentityFederation provider. +func NewWorkloadIdentityFederationProvider(cfg *WorkloadIdentityFederationProviderConfig) (*WorkloadIdentityFederationProvider, error) { + if cfg == nil { + return nil, fmt.Errorf("%s: config cannot be nil", workloadIdentityErrorPrefix) + } + tokenURL := cfg.TokenURL + if tokenURL == "" { + tokenURL = utils.GetEnvOrDefault(envIdpTokenEndpoint, wifDefaultTokenEndpoint) + } + + clientID := cfg.ClientID + if clientID == "" { + clientID = utils.GetEnvOrDefault(workloadIdentityClientIDEnv, "") + } + if clientID == "" { + return nil, fmt.Errorf("%s: client ID cannot be empty", workloadIdentityErrorPrefix) + } + + federatedTokenFunction := cfg.FederatedTokenFunction + if federatedTokenFunction == nil { + federatedTokenFunction = oidcadapters.ReadJWTFromFileSystem(utils.GetEnvOrDefault(envFederatedTokenFile, wifDefaultFederatedTokenPath)) + } + if _, err := federatedTokenFunction(context.Background()); err != nil { + return nil, fmt.Errorf("%s: error reading federated token file: %w", workloadIdentityErrorPrefix, err) + } + + httpClient := authHTTPClient(cfg.HTTPClient, time.Minute) + + leeway := cfg.TokenRefreshLeeway + if leeway == 0 { + leeway = workloadIdentityDefaultLeeway + } + + return &WorkloadIdentityFederationProvider{ + name: "WorkloadIdentityFederationProvider", + httpClient: httpClient, + tokenURL: tokenURL, + clientID: clientID, + federatedTokenFunction: federatedTokenFunction, + tokenLeeway: leeway, + scopes: cfg.Scopes, + resources: cfg.Resources, + }, nil +} + +// Token returns a valid access token. +func (p *WorkloadIdentityFederationProvider) Token(ctx context.Context, opt TokenRequestOptions) (Token, error) { + scopes, resources := effectiveRequest(opt, p.scopes, p.resources) + return p.cache.get(ctx, requestKey(scopes, resources), p.tokenLeeway, func(ctx context.Context) (Token, error) { + fresh, err := p.requestToken(ctx, scopes, resources) + if err != nil { + return Token{}, err + } + debugContext(ctx, "credentials: authenticated", "provider", p.name) + return fresh, nil + }) +} + +func (p *WorkloadIdentityFederationProvider) requestToken(ctx context.Context, scopes, resources []string) (Token, error) { + clientAssertion, err := p.federatedTokenFunction(ctx) + if err != nil { + return Token{}, fmt.Errorf("%s: get federated token: %w", workloadIdentityErrorPrefix, err) + } + + body := url.Values{} + body.Set("grant_type", wifGrantType) + body.Set("client_assertion_type", wifClientAssertionType) + body.Set("client_assertion", clientAssertion) + body.Set("client_id", p.clientID) + setScopesAndResources(body, scopes, resources) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenURL, strings.NewReader(body.Encode())) + if err != nil { + return Token{}, fmt.Errorf("%s: create token request: %w", workloadIdentityErrorPrefix, err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := p.httpClient.Do(req) + if err != nil { + return Token{}, fmt.Errorf("%s: request access token: %w", workloadIdentityErrorPrefix, err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + bodyRaw, _ := io.ReadAll(res.Body) + return Token{}, fmt.Errorf("%s: token request failed with status %d: %s", workloadIdentityErrorPrefix, res.StatusCode, string(bodyRaw)) + } + + var tokenResponse workloadIdentityTokenResponse + if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil { + return Token{}, fmt.Errorf("%s: decode token response: %w", workloadIdentityErrorPrefix, err) + } + if tokenResponse.AccessToken == "" { + return Token{}, fmt.Errorf("%s: token response did not include access token", workloadIdentityErrorPrefix) + } + + expiresOn, err := getTokenExpiration(tokenResponse.AccessToken, tokenResponse.ExpiresIn) + if err != nil { + return Token{}, err + } + + refreshOn := expiresOn.Add(-p.tokenLeeway) + return Token{ + AccessToken: tokenResponse.AccessToken, + TokenType: tokenType(tokenResponse.TokenType), + ExpiresOn: expiresOn, + RefreshOn: refreshOn, + }, nil +} diff --git a/core/credentials/workload_identity_federation_provider_test.go b/core/credentials/workload_identity_federation_provider_test.go new file mode 100644 index 0000000000..46c5929132 --- /dev/null +++ b/core/credentials/workload_identity_federation_provider_test.go @@ -0,0 +1,244 @@ +package credentials + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestWorkloadIdentityFederationToken(t *testing.T) { + assertion, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "assertion", + }).SignedString([]byte("assertion-secret")) + if err != nil { + t.Fatalf("generate assertion token: %v", err) + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("access-secret")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + var requests int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&requests, 1) + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.PostForm.Get("grant_type") != "client_credentials" { + t.Fatalf("unexpected grant type: %s", r.PostForm.Get("grant_type")) + } + if r.PostForm.Get("client_assertion_type") != "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" { + t.Fatalf("unexpected assertion type: %s", r.PostForm.Get("client_assertion_type")) + } + // The platform issues the federated token; the SDK must forward it untouched. + if got := r.PostForm.Get("client_assertion"); got != assertion { + t.Fatalf("expected the federated token to be forwarded as is, got %q", got) + } + if r.PostForm.Get("client_id") != "service-account@sa.stackit.cloud" { + t.Fatalf("unexpected client id: %s", r.PostForm.Get("client_id")) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + provider, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ + TokenURL: server.URL, + ClientID: "service-account@sa.stackit.cloud", + FederatedTokenFunction: func(context.Context) (string, error) { return assertion, nil }, + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + first, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("first token: %v", err) + } + second, err := provider.Token(context.Background(), TokenRequestOptions{}) + if err != nil { + t.Fatalf("second token: %v", err) + } + if first.AccessToken != second.AccessToken { + t.Fatalf("expected cache hit") + } + if atomic.LoadInt64(&requests) != 1 { + t.Fatalf("expected one request, got %d", requests) + } +} + +func TestWorkloadIdentityFederationRequiresClientID(t *testing.T) { + _, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ + FederatedTokenFunction: func(context.Context) (string, error) { return "token", nil }, + }) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestWorkloadIdentityFederationScopesAndResourcesOverride(t *testing.T) { + assertion, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "assertion", + }).SignedString([]byte("assertion-secret")) + if err != nil { + t.Fatalf("generate assertion token: %v", err) + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Subject: "subject", + }).SignedString([]byte("access-secret")) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + tests := []struct { + name string + configScopes []string + configResources []string + optionScopes []string + optionResources []string + expectedScopes string + expectedResources []string + }{ + { + name: "no config, no options", + configScopes: []string{}, + configResources: []string{}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "", + expectedResources: []string{}, + }, + { + name: "config scopes only", + configScopes: []string{"read", "write"}, + configResources: []string{}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "read write", + expectedResources: []string{}, + }, + { + name: "config resources only", + configScopes: []string{}, + configResources: []string{"res1", "res2"}, + optionScopes: nil, + optionResources: nil, + expectedScopes: "", + expectedResources: []string{"res1", "res2"}, + }, + { + name: "option scopes override config", + configScopes: []string{"config-scope"}, + configResources: []string{}, + optionScopes: []string{"option-scope1", "option-scope2"}, + optionResources: nil, + expectedScopes: "option-scope1 option-scope2", + expectedResources: []string{}, + }, + { + name: "option resources override config", + configScopes: []string{}, + configResources: []string{"config-res"}, + optionScopes: nil, + optionResources: []string{"option-res1", "option-res2"}, + expectedScopes: "", + expectedResources: []string{"option-res1", "option-res2"}, + }, + { + name: "option scopes and resources override config", + configScopes: []string{"config-scope"}, + configResources: []string{"config-res"}, + optionScopes: []string{"opt-scope1", "opt-scope2"}, + optionResources: []string{"opt-res1", "opt-res2"}, + expectedScopes: "opt-scope1 opt-scope2", + expectedResources: []string{"opt-res1", "opt-res2"}, + }, + { + name: "empty option scopes use config", + configScopes: []string{"config-scope"}, + configResources: []string{}, + optionScopes: []string{}, + optionResources: nil, + expectedScopes: "config-scope", + expectedResources: []string{}, + }, + { + name: "empty option resources use config", + configScopes: []string{}, + configResources: []string{"config-res"}, + optionScopes: nil, + optionResources: []string{}, + expectedScopes: "", + expectedResources: []string{"config-res"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var scopesReceived string + var resourcesReceived []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + scopesReceived = r.PostForm.Get("scope") + resourcesReceived = r.PostForm["resource"] + + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": accessToken, + "expires_in": 3600, + }) + })) + defer server.Close() + + provider, err := NewWorkloadIdentityFederationProvider(&WorkloadIdentityFederationProviderConfig{ + TokenURL: server.URL, + ClientID: "service-account@sa.stackit.cloud", + FederatedTokenFunction: func(context.Context) (string, error) { return assertion, nil }, + HTTPClient: server.Client(), + Scopes: tt.configScopes, + Resources: tt.configResources, + }) + if err != nil { + t.Fatalf("new provider: %v", err) + } + + _, err = provider.Token(context.Background(), TokenRequestOptions{ + Scopes: tt.optionScopes, + Resources: tt.optionResources, + }) + if err != nil { + t.Fatalf("token: %v", err) + } + + if scopesReceived != tt.expectedScopes { + t.Fatalf("expected scopes %q, got %q", tt.expectedScopes, scopesReceived) + } + if len(resourcesReceived) != len(tt.expectedResources) { + t.Fatalf("expected %d resources, got %d", len(tt.expectedResources), len(resourcesReceived)) + } + for i, res := range resourcesReceived { + if res != tt.expectedResources[i] { + t.Fatalf("expected resource %q at index %d, got %q", tt.expectedResources[i], i, res) + } + } + }) + } +} diff --git a/examples/authentication/authentication.go b/examples/authentication/authentication.go index 490826eade..a53f8f9e16 100644 --- a/examples/authentication/authentication.go +++ b/examples/authentication/authentication.go @@ -6,6 +6,7 @@ import ( "os" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/credentials" dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" ) @@ -41,6 +42,19 @@ func main() { os.Exit(1) } + // Create a new API client that authenticates with a token provider from the credentials package. + // NewDefaultProvider chains the supported flows (workload identity federation, service account key, token, ...). + tokenProvider, err := credentials.NewDefaultProvider(&credentials.DefaultProviderConfig{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Creating token provider: %v\n", err) + os.Exit(1) + } + _, err = dns.NewAPIClient(config.WithTokenProvider(tokenProvider)) + 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 diff --git a/examples/authentication/go.mod b/examples/authentication/go.mod index b3af472bd6..5e3249a5dc 100644 --- a/examples/authentication/go.mod +++ b/examples/authentication/go.mod @@ -5,8 +5,10 @@ go 1.25 // This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub. replace github.com/stackitcloud/stackit-sdk-go/services/dns => ../../services/dns +replace github.com/stackitcloud/stackit-sdk-go/core => ../../core + require ( - github.com/stackitcloud/stackit-sdk-go/core v0.27.1 + github.com/stackitcloud/stackit-sdk-go/core v0.28.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.23.1 ) diff --git a/examples/authentication/go.sum b/examples/authentication/go.sum index 5bae1d1250..856cf417c2 100644 --- a/examples/authentication/go.sum +++ b/examples/authentication/go.sum @@ -4,5 +4,3 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/stackitcloud/stackit-sdk-go/core v0.27.1 h1:k7SrqqMoHjkvHbx6n6nV3Lt51itp8nNplht6vGHkPhw= -github.com/stackitcloud/stackit-sdk-go/core v0.27.1/go.mod h1:WU1hhxnjXw2EV7CYa1nlEvNpMiRY6CvmIOaHuL3pOaA=