Skip to content
Draft
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
16 changes: 16 additions & 0 deletions AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,19 @@ Using this flow is less secure since the token is long-lived. You can provide th
1. Providing the flag `--service-account-token`
2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN`
3. Setting `STACKIT_SERVICE_ACCOUNT_TOKEN` in the credentials file (see above)

### Workload Identity Federation (OIDC)

1. Create a service account trusted relation in the STACKIT Portal:

- Navigate to `Service Accounts` → Select account → `Federated Identity Providers`
- [Configure a Federated Identity Provider](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider) and the required assertions. For detailed assertion configuration per platform, see the [Terraform provider WIF guide](https://github.com/stackitcloud/terraform-provider-stackit/blob/main/docs/guides/workload_identity_federation.md).

2. Configure authentication using environment variables:

```bash
STACKIT_USE_OIDC=1
STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa.stackit.cloud
# Optional: provide the OIDC token directly instead of auto-detecting it from the CI environment
STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN=<oidc-token>
```
3 changes: 3 additions & 0 deletions docs/stackit_auth_activate-service-account.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ stackit auth activate-service-account [flags]

Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands.
$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token

Authenticate via Workload Identity Federation (OIDC) and print the short-lived access token. Set STACKIT_USE_OIDC=1 and STACKIT_SERVICE_ACCOUNT_EMAIL; no service account key file is required.
$ STACKIT_USE_OIDC=1 STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --only-print-access-token
```

### Options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,22 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
`Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands.`,
"$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token",
),
examples.NewExample(
`Authenticate via Workload Identity Federation (OIDC) and print the short-lived access token. Set STACKIT_USE_OIDC=1 and STACKIT_SERVICE_ACCOUNT_EMAIL; no service account key file is required.`,
"$ STACKIT_USE_OIDC=1 STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --only-print-access-token",
),
),
RunE: func(cmd *cobra.Command, args []string) error {
model, err := parseInput(params.Printer, cmd, args)
if err != nil {
return err
}

// use workload identity federation (OIDC) if enabled; no key file required
if auth.IsOIDCEnabled() {
return runOIDCMode(params, model)
}

tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
if !model.OnlyPrintAccessToken {
if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
Expand Down Expand Up @@ -133,3 +142,50 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel,
func storeCustomEndpoint(tokenCustomEndpoint string) error {
return auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint)
}

func runOIDCMode(params *types.CmdParams, model *inputModel) error {
email := auth.OIDCServiceAccountEmail()
if email == "" {
return fmt.Errorf(
"env var %s must be set when %s is enabled",
auth.EnvServiceAccountEmail, auth.EnvUseOIDC,
)
}

tokenFunc, err := auth.OIDCTokenFunc()
if err != nil {
return err
}

tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)

wifCfg := &sdkConfig.Configuration{
WorkloadIdentityFederation: true,
ServiceAccountEmail: email,
ServiceAccountFederatedTokenFunc: tokenFunc,
TokenCustomUrl: tokenCustomEndpoint,
}

rt, err := sdkAuth.SetupAuth(wifCfg)
if err != nil {
params.Printer.Debug(print.ErrorLevel, "setup workload identity federation auth: %v", err)
return &cliErr.ActivateServiceAccountError{}
}

// credentials are never written to disk in OIDC mode
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true)
if err != nil {
var activateErr *cliErr.ActivateServiceAccountError
if !errors.As(err, &activateErr) {
return fmt.Errorf("authenticate service account via workload identity federation: %w", err)
}
return err
}

if model.OnlyPrintAccessToken {
params.Printer.Outputf("%s\n", accessToken)
} else {
params.Printer.Outputf("Authenticated via Workload Identity Federation.\nService account email: %s\n", saEmail)
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ func TestParseInput(t *testing.T) {
model.OnlyPrintAccessToken = false
}),
},
{
description: "oidc_mode_no_key_required",
flagValues: map[string]string{},
isValid: true,
expectedModel: &inputModel{
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
OnlyPrintAccessToken: false,
},
},
}

for _, tt := range tests {
Expand Down
33 changes: 33 additions & 0 deletions internal/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth"
sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
)

Expand All @@ -33,6 +34,38 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
return authCfgOption, nil
}

// use workload identity federation (OIDC) if enabled; takes priority over stored flows
if IsOIDCEnabled() {
p.Debug(print.DebugLevel, "authenticating using workload identity federation (OIDC)")

email := OIDCServiceAccountEmail()
if email == "" {
return nil, fmt.Errorf(
"env var %s must be set when %s is enabled",
EnvServiceAccountEmail, EnvUseOIDC,
)
}

tokenFunc, err := OIDCTokenFunc()
if err != nil {
return nil, err
}

wifCfg := &sdkConfig.Configuration{
WorkloadIdentityFederation: true,
ServiceAccountEmail: email,
ServiceAccountFederatedTokenFunc: tokenFunc,
TokenCustomUrl: viper.GetString(config.TokenCustomEndpointKey),
}

rt, err := sdkAuth.WorkloadIdentityFederationAuth(wifCfg)
if err != nil {
return nil, fmt.Errorf("initialize workload identity federation: %w", err)
}

return sdkConfig.WithCustomAuth(rt), nil
}

flow, err := GetAuthFlow()
if err != nil {
return nil, fmt.Errorf("get authentication flow: %w", err)
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ func TestAuthenticationConfig(t *testing.T) {

for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
t.Setenv(EnvUseOIDC, "") // ensure OIDC mode is off for these tests
keyring.MockInit()
timestamp := time.Now().Add(24 * time.Hour)
authFields := make(map[authFieldKey]string)
Expand Down
64 changes: 64 additions & 0 deletions internal/pkg/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package auth

import (
"context"
"fmt"
"os"

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

const (
EnvUseOIDC = "STACKIT_USE_OIDC"
EnvServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL"
EnvServiceAccountFederatedToken = "STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN" //nolint:gosec // linter false positive
EnvGitHubRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL"
EnvGitHubRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" //nolint:gosec // linter false positive
EnvAzureOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive
)

func IsOIDCEnabled() bool {
return os.Getenv(EnvUseOIDC) == "1"
}

func OIDCServiceAccountEmail() string {
return os.Getenv(EnvServiceAccountEmail)
}

// TokenFunc returns the OIDCTokenFunc to use for Workload Identity Federation.
// It checks the following token sources in order: STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN,
// GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL + ACTIONS_ID_TOKEN_REQUEST_TOKEN), and
// Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN).
// Returns an error if no source is detected.
func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) {
// static token provided directly via env var
if token := os.Getenv(EnvServiceAccountFederatedToken); token != "" {
return func(_ context.Context) (string, error) {
return token, nil
}, nil
}

// GitHub Actions
if ghURL := os.Getenv(EnvGitHubRequestURL); ghURL != "" {
if ghToken := os.Getenv(EnvGitHubRequestToken); ghToken != "" {
return oidcadapters.RequestGHOIDCToken(ghURL, ghToken), nil
}
}

// Azure DevOps
if adoURL := os.Getenv(EnvAzureOIDCRequestURI); adoURL != "" {
if adoToken := os.Getenv(EnvAzureAccessToken); adoToken != "" {
return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, ""), nil
}
}

return nil, fmt.Errorf(
"%s is enabled but no OIDC token source was detected\n"+
"Provide the token via %s, or run in a supported CI environment:\n"+
" - GitHub Actions: grant 'id-token: write' permission; %s and %s are set automatically by the runner\n"+
" - Azure DevOps: pass 'SYSTEM_ACCESSTOKEN: $(System.AccessToken)' in your pipeline step",
EnvUseOIDC, EnvServiceAccountFederatedToken,
EnvGitHubRequestURL, EnvGitHubRequestToken,
)
}
155 changes: 155 additions & 0 deletions internal/pkg/auth/oidc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package auth_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
)

func TestIsEnabled(t *testing.T) {
tests := []struct {
value string
expected bool
}{
{"1", true},
{"0", false},
{"", false},
{"true", false},
{"yes", false},
{"random", false},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
t.Setenv(auth.EnvUseOIDC, tt.value)
got := auth.IsOIDCEnabled()
if got != tt.expected {
t.Errorf("IsOIDCEnabled() = %v, want %v (env=%q)", got, tt.expected, tt.value)
}
})
}
}

func TestIsEnabled_Unset(t *testing.T) {
// When the env var is not set at all IsEnabled must return false
t.Setenv(auth.EnvUseOIDC, "")
if auth.IsOIDCEnabled() {
t.Error("IsOIDCEnabled() = true, want false when env var is empty")
}
}

func TestServiceAccountEmail(t *testing.T) {
const want = "ci@sa.stackit.cloud"
t.Setenv(auth.EnvServiceAccountEmail, want)
if got := auth.OIDCServiceAccountEmail(); got != want {
t.Errorf("OIDCServiceAccountEmail() = %q, want %q", got, want)
}
}

func TestTokenFunc_StaticToken(t *testing.T) {
const want = "my-static-oidc-token"
t.Setenv(auth.EnvServiceAccountFederatedToken, want)
// ensure GitHub / Azure vars are absent so we hit the static path first
t.Setenv(auth.EnvGitHubRequestURL, "")
t.Setenv(auth.EnvGitHubRequestToken, "")
t.Setenv(auth.EnvAzureOIDCRequestURI, "")
t.Setenv(auth.EnvAzureAccessToken, "")

fn, err := auth.OIDCTokenFunc()
if err != nil {
t.Fatalf("OIDCTokenFunc() unexpected error: %v", err)
}
got, err := fn(context.Background())
if err != nil {
t.Fatalf("fn() unexpected error: %v", err)
}
if got != want {
t.Errorf("fn() = %q, want %q", got, want)
}
}

func TestTokenFunc_GitHubActions(t *testing.T) {
// Spin up a fake GitHub OIDC endpoint that matches the SDK's expected format.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"value": "gh-oidc-token"})
}))
defer srv.Close()

t.Setenv(auth.EnvServiceAccountFederatedToken, "")
t.Setenv(auth.EnvGitHubRequestURL, srv.URL)
t.Setenv(auth.EnvGitHubRequestToken, "gh-bearer-token")
t.Setenv(auth.EnvAzureOIDCRequestURI, "")
t.Setenv(auth.EnvAzureAccessToken, "")

fn, err := auth.OIDCTokenFunc()
if err != nil {
t.Fatalf("OIDCTokenFunc() unexpected error: %v", err)
}
got, err := fn(context.Background())
if err != nil {
t.Fatalf("fn() unexpected error: %v", err)
}
if got != "gh-oidc-token" {
t.Errorf("fn() = %q, want %q", got, "gh-oidc-token")
}
}

func TestTokenFunc_AzureDevOps(t *testing.T) {
// Spin up a fake Azure DevOps OIDC endpoint that matches the SDK's expected format.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
tok := "ado-oidc-token"
_ = json.NewEncoder(w).Encode(map[string]*string{"oidcToken": &tok})
}))
defer srv.Close()

t.Setenv(auth.EnvServiceAccountFederatedToken, "")
t.Setenv(auth.EnvGitHubRequestURL, "")
t.Setenv(auth.EnvGitHubRequestToken, "")
t.Setenv(auth.EnvAzureOIDCRequestURI, srv.URL)
t.Setenv(auth.EnvAzureAccessToken, "ado-access-token")

fn, err := auth.OIDCTokenFunc()
if err != nil {
t.Fatalf("OIDCTokenFunc() unexpected error: %v", err)
}
got, err := fn(context.Background())
if err != nil {
t.Fatalf("fn() unexpected error: %v", err)
}
if got != "ado-oidc-token" {
t.Errorf("fn() = %q, want %q", got, "ado-oidc-token")
}
}

func TestTokenFunc_NoSource(t *testing.T) {
// All env vars absent → must return an actionable error, no panic.
t.Setenv(auth.EnvServiceAccountFederatedToken, "")
t.Setenv(auth.EnvGitHubRequestURL, "")
t.Setenv(auth.EnvGitHubRequestToken, "")
t.Setenv(auth.EnvAzureOIDCRequestURI, "")
t.Setenv(auth.EnvAzureAccessToken, "")

_, err := auth.OIDCTokenFunc()
if err == nil {
t.Fatal("OIDCTokenFunc() expected error when no OIDC source is available, got nil")
}
}

func TestTokenFunc_GitHubURL_NoToken(t *testing.T) {
// URL present but token absent → should fall through to Azure / error.
t.Setenv(auth.EnvServiceAccountFederatedToken, "")
t.Setenv(auth.EnvGitHubRequestURL, "https://example.com")
t.Setenv(auth.EnvGitHubRequestToken, "")
t.Setenv(auth.EnvAzureOIDCRequestURI, "")
t.Setenv(auth.EnvAzureAccessToken, "")

_, err := auth.OIDCTokenFunc()
if err == nil {
t.Fatal("OIDCTokenFunc() expected error when GitHub token is missing, got nil")
}
}
Loading