Skip to content

Commit bc0a6f2

Browse files
authored
feat(auth): add Workload Identity Federation (OIDC) support (#1424)
relates to STACKITCLI-337
1 parent eb09bc8 commit bc0a6f2

10 files changed

Lines changed: 547 additions & 0 deletions

File tree

AUTHENTICATION.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,28 @@ Using this flow is less secure since the token is long-lived. You can provide th
106106
1. Providing the flag `--service-account-token`
107107
2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN`
108108
3. Setting `STACKIT_SERVICE_ACCOUNT_TOKEN` in the credentials file (see above)
109+
110+
### Workload Identity Federation (OIDC)
111+
112+
1. Create a service account trusted relation in the STACKIT Portal:
113+
114+
- Navigate to `Service Accounts` → Select account → `Federated Identity Providers`
115+
- [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).
116+
117+
2. Configure authentication for `stackit auth activate-service-account` using one of the options below:
118+
119+
- Explicit flag: `--use-oidc` (takes precedence)
120+
- Environment variable: `STACKIT_USE_OIDC=1`
121+
122+
If both are provided, the explicit flag value is used.
123+
124+
Example using environment variables:
125+
126+
```bash
127+
STACKIT_USE_OIDC=1
128+
STACKIT_SERVICE_ACCOUNT_EMAIL=my-sa@sa.stackit.cloud
129+
# Optional: provide the OIDC token directly instead of auto-detecting it from the CI environment
130+
STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN=<oidc-token>
131+
# Optional: provide a file path containing the OIDC token
132+
STACKIT_FEDERATED_TOKEN_FILE=/path/to/oidc-token.jwt
133+
```

docs/stackit_auth_activate-service-account.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ stackit auth activate-service-account [flags]
2626
2727
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.
2828
$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token
29+
30+
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.
31+
$ STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --use-oidc --only-print-access-token
2932
```
3033

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

4145
### Options inherited from parent commands

internal/cmd/auth/activate-service-account/activate_service_account.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ const (
2525
serviceAccountTokenFlag = "service-account-token"
2626
serviceAccountKeyPathFlag = "service-account-key-path"
2727
privateKeyPathFlag = "private-key-path"
28+
useOIDCFlag = "use-oidc"
2829
onlyPrintAccessTokenFlag = "only-print-access-token" // #nosec G101
2930
)
3031

3132
type inputModel struct {
3233
ServiceAccountToken string
3334
ServiceAccountKeyPath string
3435
PrivateKeyPath string
36+
UseOIDC *bool
3537
OnlyPrintAccessToken bool
3638
}
3739

@@ -59,13 +61,22 @@ func NewCmd(params *types.CmdParams) *cobra.Command {
5961
`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.`,
6062
"$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token",
6163
),
64+
examples.NewExample(
65+
`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.`,
66+
"$ STACKIT_SERVICE_ACCOUNT_EMAIL=ci@sa.stackit.cloud stackit auth activate-service-account --use-oidc --only-print-access-token",
67+
),
6268
),
6369
RunE: func(cmd *cobra.Command, args []string) error {
6470
model, err := parseInput(params.Printer, cmd, args)
6571
if err != nil {
6672
return err
6773
}
6874

75+
// use workload identity federation (OIDC) if enabled; no key file required
76+
if auth.IsOIDCEnabledWithOverride(model.UseOIDC) {
77+
return runOIDCMode(params, model)
78+
}
79+
6980
tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
7081
if !model.OnlyPrintAccessToken {
7182
if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
@@ -115,6 +126,7 @@ func configureFlags(cmd *cobra.Command) {
115126
cmd.Flags().String(serviceAccountTokenFlag, "", "Service account long-lived access token")
116127
cmd.Flags().String(serviceAccountKeyPathFlag, "", "Service account key path")
117128
cmd.Flags().String(privateKeyPathFlag, "", "RSA private key path. It takes precedence over the private key included in the service account key, if present")
129+
cmd.Flags().Bool(useOIDCFlag, false, "Use Workload Identity Federation (OIDC). If set, this takes precedence over STACKIT_USE_OIDC")
118130
cmd.Flags().Bool(onlyPrintAccessTokenFlag, false, "If this is set to true the credentials are not stored in either the keyring or a file")
119131
}
120132

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

129145
p.DebugInputModel(model)
130146
return &model, nil
@@ -133,3 +149,50 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel,
133149
func storeCustomEndpoint(tokenCustomEndpoint string) error {
134150
return auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint)
135151
}
152+
153+
func runOIDCMode(params *types.CmdParams, model *inputModel) error {
154+
email := auth.OIDCServiceAccountEmail()
155+
if email == "" {
156+
return fmt.Errorf(
157+
"env var %s must be set when %s is enabled",
158+
auth.EnvServiceAccountEmail, auth.EnvUseOIDC,
159+
)
160+
}
161+
162+
tokenFunc, err := auth.OIDCTokenFunc()
163+
if err != nil {
164+
return err
165+
}
166+
167+
tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
168+
169+
wifCfg := &sdkConfig.Configuration{
170+
WorkloadIdentityFederation: true,
171+
ServiceAccountEmail: email,
172+
ServiceAccountFederatedTokenFunc: tokenFunc,
173+
TokenCustomUrl: tokenCustomEndpoint,
174+
}
175+
176+
rt, err := sdkAuth.SetupAuth(wifCfg)
177+
if err != nil {
178+
params.Printer.Debug(print.ErrorLevel, "setup workload identity federation auth: %v", err)
179+
return &cliErr.ActivateServiceAccountError{}
180+
}
181+
182+
// credentials are never written to disk in OIDC mode
183+
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true)
184+
if err != nil {
185+
var activateErr *cliErr.ActivateServiceAccountError
186+
if !errors.As(err, &activateErr) {
187+
return fmt.Errorf("authenticate service account via workload identity federation: %w", err)
188+
}
189+
return err
190+
}
191+
192+
if model.OnlyPrintAccessToken {
193+
params.Printer.Outputf("%s\n", accessToken)
194+
} else {
195+
params.Printer.Outputf("Authenticated via Workload Identity Federation.\nService account email: %s\n", saEmail)
196+
}
197+
return nil
198+
}

internal/cmd/auth/activate-service-account/activate_service_account_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
7+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
78

89
"github.com/spf13/viper"
910
"github.com/zalando/go-keyring"
@@ -19,6 +20,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
1920
serviceAccountTokenFlag: "token",
2021
serviceAccountKeyPathFlag: "sa_key",
2122
privateKeyPathFlag: "private_key",
23+
useOIDCFlag: "true",
2224
onlyPrintAccessTokenFlag: "true",
2325
}
2426
for _, mod := range mods {
@@ -32,6 +34,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
3234
ServiceAccountToken: "token",
3335
ServiceAccountKeyPath: "sa_key",
3436
PrivateKeyPath: "private_key",
37+
UseOIDC: utils.Ptr(true),
3538
OnlyPrintAccessToken: true,
3639
}
3740
for _, mod := range mods {
@@ -65,6 +68,7 @@ func TestParseInput(t *testing.T) {
6568
ServiceAccountToken: "",
6669
ServiceAccountKeyPath: "",
6770
PrivateKeyPath: "",
71+
UseOIDC: nil,
6872
},
6973
},
7074
{
@@ -80,8 +84,29 @@ func TestParseInput(t *testing.T) {
8084
ServiceAccountToken: "",
8185
ServiceAccountKeyPath: "",
8286
PrivateKeyPath: "",
87+
UseOIDC: nil,
8388
},
8489
},
90+
{
91+
description: "use_oidc_true",
92+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
93+
flagValues[useOIDCFlag] = "true"
94+
}),
95+
isValid: true,
96+
expectedModel: fixtureInputModel(func(model *inputModel) {
97+
model.UseOIDC = utils.Ptr(true)
98+
}),
99+
},
100+
{
101+
description: "use_oidc_false",
102+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
103+
flagValues[useOIDCFlag] = "false"
104+
}),
105+
isValid: true,
106+
expectedModel: fixtureInputModel(func(model *inputModel) {
107+
model.UseOIDC = utils.Ptr(false)
108+
}),
109+
},
85110
{
86111
description: "invalid_flag",
87112
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
@@ -101,6 +126,18 @@ func TestParseInput(t *testing.T) {
101126
model.OnlyPrintAccessToken = false
102127
}),
103128
},
129+
{
130+
description: "default value UseOIDC",
131+
flagValues: fixtureFlagValues(
132+
func(flagValues map[string]string) {
133+
delete(flagValues, useOIDCFlag)
134+
},
135+
),
136+
isValid: true,
137+
expectedModel: fixtureInputModel(func(model *inputModel) {
138+
model.UseOIDC = nil
139+
}),
140+
},
104141
}
105142

106143
for _, tt := range tests {

internal/pkg/auth/auth.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/golang-jwt/jwt/v5"
1414
"github.com/spf13/viper"
15+
sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth"
1516
sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
1617
)
1718

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

37+
// use workload identity federation (OIDC) if enabled; takes priority over stored flows
38+
if IsOIDCEnabled() {
39+
p.Debug(print.DebugLevel, "authenticating using workload identity federation (OIDC)")
40+
41+
email := OIDCServiceAccountEmail()
42+
if email == "" {
43+
return nil, fmt.Errorf(
44+
"env var %s must be set when %s is enabled",
45+
EnvServiceAccountEmail, EnvUseOIDC,
46+
)
47+
}
48+
49+
tokenFunc, err := OIDCTokenFunc()
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
wifCfg := &sdkConfig.Configuration{
55+
WorkloadIdentityFederation: true,
56+
ServiceAccountEmail: email,
57+
ServiceAccountFederatedTokenFunc: tokenFunc,
58+
TokenCustomUrl: viper.GetString(config.TokenCustomEndpointKey),
59+
}
60+
61+
rt, err := sdkAuth.WorkloadIdentityFederationAuth(wifCfg)
62+
if err != nil {
63+
return nil, fmt.Errorf("initialize workload identity federation: %w", err)
64+
}
65+
66+
return sdkConfig.WithCustomAuth(rt), nil
67+
}
68+
3669
flow, err := GetAuthFlow()
3770
if err != nil {
3871
return nil, fmt.Errorf("get authentication flow: %w", err)

internal/pkg/auth/auth_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ func TestAuthenticationConfig(t *testing.T) {
155155

156156
for _, tt := range tests {
157157
t.Run(tt.description, func(t *testing.T) {
158+
t.Setenv(EnvUseOIDC, "") // ensure OIDC mode is off for these tests
158159
keyring.MockInit()
159160
timestamp := time.Now().Add(24 * time.Hour)
160161
authFields := make(map[authFieldKey]string)

internal/pkg/auth/oidc.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
"github.com/stackitcloud/stackit-sdk-go/core/oidcadapters"
9+
)
10+
11+
const (
12+
EnvUseOIDC = "STACKIT_USE_OIDC"
13+
EnvServiceAccountEmail = "STACKIT_SERVICE_ACCOUNT_EMAIL"
14+
EnvServiceAccountFederatedToken = "STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN" //nolint:gosec // linter false positive
15+
EnvFederatedTokenFile = "STACKIT_FEDERATED_TOKEN_FILE" //nolint:gosec // linter false positive
16+
EnvGitHubRequestURL = "ACTIONS_ID_TOKEN_REQUEST_URL"
17+
EnvGitHubRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" //nolint:gosec // linter false positive
18+
EnvAzureOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
19+
EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive
20+
)
21+
22+
func IsOIDCEnabled() bool {
23+
return os.Getenv(EnvUseOIDC) == "1"
24+
}
25+
26+
// IsOIDCEnabledWithOverride resolves OIDC mode using explicit input first and env fallback.
27+
// If useOIDC is not nil, its value is used directly; otherwise STACKIT_USE_OIDC is evaluated.
28+
func IsOIDCEnabledWithOverride(useOIDC *bool) bool {
29+
if useOIDC != nil {
30+
return *useOIDC
31+
}
32+
33+
return IsOIDCEnabled()
34+
}
35+
36+
func OIDCServiceAccountEmail() string {
37+
return os.Getenv(EnvServiceAccountEmail)
38+
}
39+
40+
// TokenFunc returns the OIDCTokenFunc to use for Workload Identity Federation.
41+
// It checks the following token sources in order: STACKIT_SERVICE_ACCOUNT_FEDERATED_TOKEN,
42+
// STACKIT_FEDERATED_TOKEN_FILE, GitHub Actions (ACTIONS_ID_TOKEN_REQUEST_URL +
43+
// ACTIONS_ID_TOKEN_REQUEST_TOKEN), and Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN).
44+
// Returns an error if no source is detected.
45+
func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) {
46+
// static token provided directly via env var
47+
if token := os.Getenv(EnvServiceAccountFederatedToken); token != "" {
48+
return func(_ context.Context) (string, error) {
49+
return token, nil
50+
}, nil
51+
}
52+
53+
// token read from filesystem path via env var
54+
if tokenFilePath := os.Getenv(EnvFederatedTokenFile); tokenFilePath != "" {
55+
return oidcadapters.ReadJWTFromFileSystem(tokenFilePath), nil
56+
}
57+
58+
// GitHub Actions
59+
if ghURL := os.Getenv(EnvGitHubRequestURL); ghURL != "" {
60+
if ghToken := os.Getenv(EnvGitHubRequestToken); ghToken != "" {
61+
return oidcadapters.RequestGHOIDCToken(ghURL, ghToken), nil
62+
}
63+
}
64+
65+
// Azure DevOps
66+
if adoURL := os.Getenv(EnvAzureOIDCRequestURI); adoURL != "" {
67+
if adoToken := os.Getenv(EnvAzureAccessToken); adoToken != "" {
68+
return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, ""), nil
69+
}
70+
}
71+
72+
return nil, fmt.Errorf(
73+
"%s is enabled but no OIDC token source was detected\n"+
74+
"Provide the token via %s or %s, or run in a supported CI environment:\n"+
75+
" - GitHub Actions: grant 'id-token: write' permission; %s and %s are set automatically by the runner\n"+
76+
" - Azure DevOps: pass 'SYSTEM_ACCESSTOKEN: $(System.AccessToken)' in your pipeline step",
77+
EnvUseOIDC, EnvServiceAccountFederatedToken, EnvFederatedTokenFile,
78+
EnvGitHubRequestURL, EnvGitHubRequestToken,
79+
)
80+
}

0 commit comments

Comments
 (0)