From 7c2486ffbea7eb8544b6c2c366d7c8bdef982878 Mon Sep 17 00:00:00 2001 From: Jorge Turrado Date: Sun, 18 Oct 2026 17:22:07 +0200 Subject: [PATCH] feat(credentials): add the core/credentials package and config.WithTokenProvider Add core/credentials, a package of modular token providers built on one contract, TokenProvider, which returns an access token and nothing else: - WorkloadIdentityFederationProvider, ServiceAccountKeyProvider, StaticTokenProvider, InstanceMetadataProvider and CLIProvider, one per authentication flow. - ChainedProvider, to try providers in order, and NewDefaultProvider, the default chain: workload identity federation, service account key, static token, instance metadata and, only if enabled, the STACKIT CLI session. - Each provider caches one token per combination of scopes and resources and shares a single refresh between concurrent callers. - The service account key and the private key can be given as content or as a path, in the config or in the environment, and the config wins over the environment. Add config.WithTokenProvider, which makes auth.DefaultAuth authenticate through the given provider. The existing authentication flows are not modified, and options that explicitly choose how to authenticate still take precedence over it. Update the authentication example and the documentation, including a README for the package. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + README.md | 14 + core/CHANGELOG.md | 3 + core/VERSION | 2 +- core/auth/auth.go | 10 + core/auth/default_auth_token_provider_test.go | 65 ++ core/auth/token_provider_roundtripper.go | 54 ++ core/auth/token_provider_roundtripper_test.go | 104 +++ core/config/config.go | 11 + core/credentials/README.md | 225 ++++++ core/credentials/auth_constants.go | 21 + core/credentials/chained_provider.go | 130 ++++ core/credentials/chained_provider_test.go | 143 ++++ core/credentials/cli_provider.go | 173 +++++ core/credentials/cli_provider_test.go | 175 +++++ core/credentials/credentials.go | 79 +++ core/credentials/credentials_test.go | 91 +++ core/credentials/default_provider.go | 220 ++++++ core/credentials/default_provider_test.go | 435 ++++++++++++ core/credentials/doc.go | 16 + core/credentials/env_vars.go | 25 + core/credentials/http_client.go | 29 + .../credentials/instance_metadata_provider.go | 132 ++++ .../instance_metadata_provider_test.go | 115 +++ core/credentials/log.go | 42 ++ core/credentials/resolve.go | 79 +++ .../service_account_key_provider.go | 250 +++++++ .../service_account_key_provider_test.go | 654 ++++++++++++++++++ .../service_account_key_resolution_test.go | 228 ++++++ core/credentials/static_token_provider.go | 65 ++ .../credentials/static_token_provider_test.go | 37 + core/credentials/token.go | 49 ++ core/credentials/token_cache.go | 160 +++++ core/credentials/token_cache_test.go | 328 +++++++++ core/credentials/token_provider.go | 23 + core/credentials/types.go | 30 + .../workload_identity_federation_provider.go | 172 +++++ ...kload_identity_federation_provider_test.go | 244 +++++++ examples/authentication/authentication.go | 14 + examples/authentication/go.mod | 4 +- examples/authentication/go.sum | 2 - 41 files changed, 4651 insertions(+), 4 deletions(-) create mode 100644 core/auth/default_auth_token_provider_test.go create mode 100644 core/auth/token_provider_roundtripper.go create mode 100644 core/auth/token_provider_roundtripper_test.go create mode 100644 core/credentials/README.md create mode 100644 core/credentials/auth_constants.go create mode 100644 core/credentials/chained_provider.go create mode 100644 core/credentials/chained_provider_test.go create mode 100644 core/credentials/cli_provider.go create mode 100644 core/credentials/cli_provider_test.go create mode 100644 core/credentials/credentials.go create mode 100644 core/credentials/credentials_test.go create mode 100644 core/credentials/default_provider.go create mode 100644 core/credentials/default_provider_test.go create mode 100644 core/credentials/doc.go create mode 100644 core/credentials/env_vars.go create mode 100644 core/credentials/http_client.go create mode 100644 core/credentials/instance_metadata_provider.go create mode 100644 core/credentials/instance_metadata_provider_test.go create mode 100644 core/credentials/log.go create mode 100644 core/credentials/resolve.go create mode 100644 core/credentials/service_account_key_provider.go create mode 100644 core/credentials/service_account_key_provider_test.go create mode 100644 core/credentials/service_account_key_resolution_test.go create mode 100644 core/credentials/static_token_provider.go create mode 100644 core/credentials/static_token_provider_test.go create mode 100644 core/credentials/token.go create mode 100644 core/credentials/token_cache.go create mode 100644 core/credentials/token_cache_test.go create mode 100644 core/credentials/token_provider.go create mode 100644 core/credentials/types.go create mode 100644 core/credentials/workload_identity_federation_provider.go create mode 100644 core/credentials/workload_identity_federation_provider_test.go 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=