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
25 changes: 25 additions & 0 deletions AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,28 @@ 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 for `stackit auth activate-service-account` using one of the options below:

- Explicit flag: `--use-oidc` (takes precedence)
- Environment variable: `STACKIT_USE_OIDC=1`

If both are provided, the explicit flag value is used.

Example 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>
# Optional: provide a file path containing the OIDC token
STACKIT_FEDERATED_TOKEN_FILE=/path/to/oidc-token.jwt
```
4 changes: 4 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. Use --use-oidc to explicitly enable OIDC (takes precedence over STACKIT_USE_OIDC); no service account key file is required.
$ STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --use-oidc --only-print-access-token
```

### Options
Expand All @@ -36,6 +39,7 @@ stackit auth activate-service-account [flags]
--private-key-path string RSA private key path. It takes precedence over the private key included in the service account key, if present
--service-account-key-path string Service account key path
--service-account-token string Service account long-lived access token
--use-oidc Use Workload Identity Federation (OIDC). If set, this takes precedence over STACKIT_USE_OIDC
```

### Options inherited from parent commands
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ const (
serviceAccountTokenFlag = "service-account-token"
serviceAccountKeyPathFlag = "service-account-key-path"
privateKeyPathFlag = "private-key-path"
useOIDCFlag = "use-oidc"
onlyPrintAccessTokenFlag = "only-print-access-token" // #nosec G101
)

type inputModel struct {
ServiceAccountToken string
ServiceAccountKeyPath string
PrivateKeyPath string
UseOIDC *bool
OnlyPrintAccessToken bool
}

Expand Down Expand Up @@ -59,13 +61,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. Use --use-oidc to explicitly enable OIDC (takes precedence over STACKIT_USE_OIDC); no service account key file is required.`,
"$ STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --use-oidc --only-print-access-token",
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the TFP implementation against this implementation and found one thing which is different.

The TFP implementation allows to provide a use_oidc in the provider configuration. If this is set this has higher priority (than the environment variable).

See: https://github.com/stackitcloud/terraform-provider-stackit/blob/main/docs/index.md#authentication

1. Explicit configuration, e.g. by setting the field `use_oidc` in the provider block (see example below)
2. Environment variable, e.g. by setting `STACKIT_USE_OIDC`
3. Credentials file

In order to use the same mechanism here we need to add a cli flag as well (if provided -> cli flag has prio, if not environment variable is taken).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added supported for --use-oidc and also STACKIT_FEDERATED_TOKEN_FILE

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.IsOIDCEnabledWithOverride(model.UseOIDC) {
return runOIDCMode(params, model)
}

tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
if !model.OnlyPrintAccessToken {
if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
Expand Down Expand Up @@ -115,6 +126,7 @@ func configureFlags(cmd *cobra.Command) {
cmd.Flags().String(serviceAccountTokenFlag, "", "Service account long-lived access token")
cmd.Flags().String(serviceAccountKeyPathFlag, "", "Service account key path")
cmd.Flags().String(privateKeyPathFlag, "", "RSA private key path. It takes precedence over the private key included in the service account key, if present")
cmd.Flags().Bool(useOIDCFlag, false, "Use Workload Identity Federation (OIDC). If set, this takes precedence over STACKIT_USE_OIDC")
cmd.Flags().Bool(onlyPrintAccessTokenFlag, false, "If this is set to true the credentials are not stored in either the keyring or a file")
}

Expand All @@ -125,6 +137,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel,
PrivateKeyPath: flags.FlagToStringValue(p, cmd, privateKeyPathFlag),
OnlyPrintAccessToken: flags.FlagToBoolValue(p, cmd, onlyPrintAccessTokenFlag),
}
if cmd.Flags().Changed(useOIDCFlag) {
useOIDC := flags.FlagToBoolValue(p, cmd, useOIDCFlag)
model.UseOIDC = &useOIDC
}

p.DebugInputModel(model)
return &model, nil
Expand All @@ -133,3 +149,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 @@ -14,11 +14,16 @@ import (

var testTokenCustomEndpoint = "token_url"

func boolPtr(v bool) *bool {
return &v
}

func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
serviceAccountTokenFlag: "token",
serviceAccountKeyPathFlag: "sa_key",
privateKeyPathFlag: "private_key",
useOIDCFlag: "true",
onlyPrintAccessTokenFlag: "true",
}
for _, mod := range mods {
Expand All @@ -32,6 +37,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
ServiceAccountToken: "token",
ServiceAccountKeyPath: "sa_key",
PrivateKeyPath: "private_key",
UseOIDC: boolPtr(true),
OnlyPrintAccessToken: true,
}
for _, mod := range mods {
Expand Down Expand Up @@ -65,6 +71,7 @@ func TestParseInput(t *testing.T) {
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
UseOIDC: nil,
},
},
{
Expand All @@ -80,8 +87,29 @@ func TestParseInput(t *testing.T) {
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
UseOIDC: nil,
},
},
{
description: "use_oidc_true",
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
flagValues[useOIDCFlag] = "true"
}),
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.UseOIDC = boolPtr(true)
}),
},
{
description: "use_oidc_false",
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
flagValues[useOIDCFlag] = "false"
}),
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.UseOIDC = boolPtr(false)
}),
},
{
description: "invalid_flag",
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
Expand All @@ -101,6 +129,18 @@ func TestParseInput(t *testing.T) {
model.OnlyPrintAccessToken = false
}),
},
{
description: "default value UseOIDC",
flagValues: fixtureFlagValues(
func(flagValues map[string]string) {
delete(flagValues, useOIDCFlag)
},
),
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.UseOIDC = nil
}),
},
}

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
80 changes: 80 additions & 0 deletions internal/pkg/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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
EnvFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE"
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"
}

// IsOIDCEnabledWithOverride resolves OIDC mode using explicit input first and env fallback.
// If useOIDC is not nil, its value is used directly; otherwise STACKIT_USE_OIDC is evaluated.
func IsOIDCEnabledWithOverride(useOIDC *bool) bool {
if useOIDC != nil {
return *useOIDC
}

return IsOIDCEnabled()
}

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,
// STACKIT_FEDERATED_TOKEN_FILE, 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
}

// token read from filesystem path via env var
if tokenFilePath := os.Getenv(EnvFederatedTokenFile); tokenFilePath != "" {
return oidcadapters.ReadJWTFromFileSystem(tokenFilePath), 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 %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, EnvFederatedTokenFile,
EnvGitHubRequestURL, EnvGitHubRequestToken,
)
}
Loading