Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v0.28.0
- **Feature:** 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

Expand Down
2 changes: 1 addition & 1 deletion core/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.27.1
v0.28.0
10 changes: 10 additions & 0 deletions core/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,23 @@ 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) {
if cfg == nil {
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 {
Expand Down
65 changes: 65 additions & 0 deletions core/auth/default_auth_token_provider_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
54 changes: 54 additions & 0 deletions core/auth/token_provider_roundtripper.go
Original file line number Diff line number Diff line change
@@ -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)
}
104 changes: 104 additions & 0 deletions core/auth/token_provider_roundtripper_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
11 changes: 11 additions & 0 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading