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
11 changes: 11 additions & 0 deletions AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,14 @@ Using this flow is less secure since the token is long-lived. You can provide th
# Optional: provide a file path containing the OIDC token
STACKIT_FEDERATED_TOKEN_FILE=/path/to/oidc-token.jwt
```

When running in Azure DevOps with the [STACKIT Service Connection](https://marketplace.visualstudio.com/items?itemName=SchwarzDigits.stackit-service-connection), the CLI reads `STACKIT_SERVICE_CONNECTION_ID` and requests the OIDC token for that service connection.

3. Activate the service account and run commands as usual:

```bash
stackit auth activate-service-account --use-oidc
stackit project list
```

As with a service account key, the following commands use the activated service account. Only the service account email is stored: the OIDC token is read from the environment every time a new access token is needed, so the token source must still be available when running those commands (e.g. in the same CI job).
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ func runOIDCMode(params *types.CmdParams, model *inputModel) error {
}

tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
if !model.OnlyPrintAccessToken {
if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
return err
}
}

wifCfg := &sdkConfig.Configuration{
WorkloadIdentityFederation: true,
Expand All @@ -179,8 +184,8 @@ func runOIDCMode(params *types.CmdParams, model *inputModel) error {
return &cliErr.ActivateServiceAccountError{}
}

// credentials are never written to disk in OIDC mode
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true)
// only the service account email is stored, the federated token is read from the environment on every command
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, model.OnlyPrintAccessToken)
if err != nil {
var activateErr *cliErr.ActivateServiceAccountError
if !errors.As(err, &activateErr) {
Expand Down
19 changes: 19 additions & 0 deletions internal/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
return nil, fmt.Errorf("initialize service account key flow: %w", err)
}
authCfgOption = sdkConfig.WithCustomAuth(keyFlow)
case AUTH_FLOW_WORKLOAD_IDENTITY:
p.Debug(print.DebugLevel, "authenticating using workload identity federation")
if userSessionExpired {
return nil, fmt.Errorf("session expired")
}
wifFlow, err := initWIFFlowWithStorage()
if err != nil {
return nil, fmt.Errorf("initialize workload identity federation flow: %w", err)
}
authCfgOption = sdkConfig.WithCustomAuth(wifFlow)
case AUTH_FLOW_USER_TOKEN:
p.Debug(print.DebugLevel, "authenticating using user token")
if userSessionExpired {
Expand Down Expand Up @@ -195,6 +205,15 @@ func GetValidAccessToken(p *print.Printer) (string, error) {
return GetAccessToken()
}

// For workload identity federation, request a new token if the stored one expired
if flow == AUTH_FLOW_WORKLOAD_IDENTITY {
wifFlow, err := initWIFFlowWithStorage()
if err != nil {
return "", fmt.Errorf("initialize workload identity federation flow: %w", err)
}
return wifFlow.GetAccessToken()
}

if flow != AUTH_FLOW_USER_TOKEN {
return "", fmt.Errorf("unsupported authentication flow: %s", flow)
}
Expand Down
45 changes: 45 additions & 0 deletions internal/pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func TestAuthenticationConfig(t *testing.T) {
saKey string
privateKeySet bool
tokenEndpoint string
saEmail string
federatedToken string
isValid bool
expectedCustomAuthSet bool
expectedTokenSet bool
Expand Down Expand Up @@ -119,6 +121,41 @@ func TestAuthenticationConfig(t *testing.T) {
tokenEndpoint: "token_url",
isValid: false,
},
{
description: "base_workload_identity",
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
sessionExpiresAt: time.Now().Add(time.Hour),
accessTokenSet: true,
saEmail: "ci@sa.stackit.cloud",
federatedToken: "federated_token",
isValid: true,
expectedCustomAuthSet: true,
},
{
description: "workload_identity_session_expired",
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
sessionExpiresAt: time.Now().Add(-time.Hour),
accessTokenSet: true,
saEmail: "ci@sa.stackit.cloud",
federatedToken: "federated_token",
isValid: false,
},
{
description: "workload_identity_no_federated_token_source",
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
sessionExpiresAt: time.Now().Add(time.Hour),
accessTokenSet: true,
saEmail: "ci@sa.stackit.cloud",
isValid: false,
},
{
description: "workload_identity_no_email",
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
sessionExpiresAt: time.Now().Add(time.Hour),
accessTokenSet: true,
federatedToken: "federated_token",
isValid: false,
},
{
description: "base_user_token",
flow: AUTH_FLOW_USER_TOKEN,
Expand Down Expand Up @@ -156,6 +193,13 @@ 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
// only the static federated token is used as OIDC token source in these tests
t.Setenv(EnvServiceAccountFederatedToken, tt.federatedToken)
t.Setenv(EnvFederatedTokenFile, "")
t.Setenv(EnvGitHubRequestURL, "")
t.Setenv(EnvGitHubRequestToken, "")
t.Setenv(EnvAzureOIDCRequestURI, "")
t.Setenv(EnvAzureAccessToken, "")
keyring.MockInit()
timestamp := time.Now().Add(24 * time.Hour)
authFields := make(map[authFieldKey]string)
Expand All @@ -182,6 +226,7 @@ func TestAuthenticationConfig(t *testing.T) {
authFields[REFRESH_TOKEN] = tt.refreshToken
authFields[SERVICE_ACCOUNT_KEY] = tt.saKey
authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint
authFields[SERVICE_ACCOUNT_EMAIL] = tt.saEmail

err = SetAuthFlow(tt.flow)
if err != nil {
Expand Down
6 changes: 4 additions & 2 deletions internal/pkg/auth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
EnvGitHubRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" //nolint:gosec // linter false positive
EnvAzureOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
EnvAzureAccessToken = "SYSTEM_ACCESSTOKEN" //nolint:gosec // linter false positive
EnvAzureServiceConnectionID = "STACKIT_SERVICE_CONNECTION_ID"
)

func IsOIDCEnabled() bool {
Expand All @@ -40,7 +41,8 @@ func OIDCServiceAccountEmail() string {
// 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).
// ACTIONS_ID_TOKEN_REQUEST_TOKEN), and Azure DevOps (SYSTEM_OIDCREQUESTURI + SYSTEM_ACCESSTOKEN,
// optionally scoped to the service connection set in STACKIT_SERVICE_CONNECTION_ID).
// Returns an error if no source is detected.
func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) {
// static token provided directly via env var
Expand All @@ -65,7 +67,7 @@ func OIDCTokenFunc() (oidcadapters.OIDCTokenFunc, error) {
// Azure DevOps
if adoURL := os.Getenv(EnvAzureOIDCRequestURI); adoURL != "" {
if adoToken := os.Getenv(EnvAzureAccessToken); adoToken != "" {
return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, ""), nil
return oidcadapters.RequestAzureDevOpsOIDCToken(adoURL, adoToken, os.Getenv(EnvAzureServiceConnectionID)), nil
}
}

Expand Down
57 changes: 57 additions & 0 deletions internal/pkg/auth/oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
Expand Down Expand Up @@ -169,6 +170,62 @@ func TestTokenFunc_AzureDevOps(t *testing.T) {
}
}

func TestTokenFunc_AzureDevOps_ServiceConnectionID(t *testing.T) {
tests := []struct {
description string
serviceConnectionID string
}{
{
description: "service connection id set",
serviceConnectionID: "my-service-connection-id",
},
{
description: "service connection id unset",
serviceConnectionID: "",
},
}

for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
var gotQuery url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.Query()
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.EnvFederatedTokenFile, "")
t.Setenv(auth.EnvGitHubRequestURL, "")
t.Setenv(auth.EnvGitHubRequestToken, "")
t.Setenv(auth.EnvAzureOIDCRequestURI, srv.URL)
t.Setenv(auth.EnvAzureAccessToken, "ado-access-token")
t.Setenv(auth.EnvAzureServiceConnectionID, tt.serviceConnectionID)

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

got, ok := gotQuery["serviceConnectionId"]
if tt.serviceConnectionID == "" {
if ok {
t.Errorf("serviceConnectionId query param = %q, want it absent", got)
}
return
}
if !ok || len(got) != 1 || got[0] != tt.serviceConnectionID {
t.Errorf("serviceConnectionId query param = %q, want %q", got, tt.serviceConnectionID)
}
})
}
}

func TestTokenFunc_NoSource(t *testing.T) {
// All env vars absent → must return an actionable error, no panic.
t.Setenv(auth.EnvServiceAccountFederatedToken, "")
Expand Down
37 changes: 35 additions & 2 deletions internal/pkg/auth/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW
authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken
case wifFlowInterface:
p.Debug(print.DebugLevel, "authenticating using workload identity federation")
authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_KEY
authFlowType = AUTH_FLOW_WORKLOAD_IDENTITY

accessToken, err := flow.GetAccessToken()
if err != nil {
p.Debug(print.ErrorLevel, "get workload identity access token: %v", err)
return "", "", &errors.ActivateServiceAccountError{}
}
// only the service account email is stored; the federated token is read from the environment on every refresh
authFields[ACCESS_TOKEN] = accessToken
disableWriting = true
authFields[SERVICE_ACCOUNT_KEY] = ""
authFields[PRIVATE_KEY] = ""
default:
return "", "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue")
}
Expand Down Expand Up @@ -149,6 +151,37 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) {
return keyFlowWithStorage, nil
}

// initWIFFlowWithStorage initializes the workload identity federation flow for the service account stored in the auth storage.
// The federated token is not stored, it is read from the environment every time the access token is refreshed.
func initWIFFlowWithStorage() (*clients.WorkloadIdentityFederationFlow, error) {
authFields := map[authFieldKey]string{
SERVICE_ACCOUNT_EMAIL: "",
TOKEN_CUSTOM_ENDPOINT: "",
}
err := GetAuthFieldMap(authFields)
if err != nil {
return nil, fmt.Errorf("get from auth storage: %w", err)
}

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

cfg := &clients.WorkloadIdentityFederationFlowConfig{
TokenUrl: authFields[TOKEN_CUSTOM_ENDPOINT],
ClientID: authFields[SERVICE_ACCOUNT_EMAIL],
FederatedTokenFunction: tokenFunc,
}

wifFlow := &clients.WorkloadIdentityFederationFlow{}
err = wifFlow.Init(cfg)
if err != nil {
return nil, fmt.Errorf("initialize workload identity federation flow: %w", err)
}
return wifFlow, nil
}

// The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access token
func (kf *keyFlowWithStorage) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := kf.keyFlow.RoundTrip(req)
Expand Down
48 changes: 40 additions & 8 deletions internal/pkg/auth/service_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,15 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
isValid bool
}{
{
description: "wif_success_no_credentials_written",
description: "wif_success_credentials_written",
accessToken: raw,
disableWriting: false, // even when false, WIF forces no-write internally
disableWriting: false,
isValid: true,
},
{
description: "wif_success_writing_disabled",
accessToken: raw,
disableWriting: true,
isValid: true,
},
{
Expand All @@ -224,6 +230,17 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
keyring.MockInit()
config.InitConfig()

// leftovers from a previous key flow must not survive a WIF activation
if err := SetAuthFieldMap(map[authFieldKey]string{
SERVICE_ACCOUNT_KEY: "old_key",
PRIVATE_KEY: "old_private_key",
}); err != nil {
t.Fatalf("set previous credentials: %v", err)
}
if err := SetAuthFlow(AUTH_FLOW_SERVICE_ACCOUNT_KEY); err != nil {
t.Fatalf("set previous auth flow: %v", err)
}

flow := &wifFlowMocked{
accessToken: tt.accessToken,
getAccessTokenFail: tt.getAccessTokenFail,
Expand All @@ -245,16 +262,31 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
t.Fatalf("email = %q, want %q", email, testEmail)
}

// Verify no credentials were written to the keyring / file.
// After a WIF authentication, the auth storage should not contain a
// service account key or private key.
storedFlow, _ := GetAuthFlow()
storedEmail, _ := GetAuthField(SERVICE_ACCOUNT_EMAIL)
storedKey, _ := GetAuthField(SERVICE_ACCOUNT_KEY)
storedPrivKey, _ := GetAuthField(PRIVATE_KEY)

if tt.disableWriting {
if storedFlow != AUTH_FLOW_SERVICE_ACCOUNT_KEY {
t.Errorf("auth flow = %q, want previous flow %q untouched when writing is disabled", storedFlow, AUTH_FLOW_SERVICE_ACCOUNT_KEY)
}
return
}

// Only the flow and the service account email are needed to rebuild the WIF flow,
// the federated token is read from the environment on every command.
if storedFlow != AUTH_FLOW_WORKLOAD_IDENTITY {
t.Errorf("auth flow = %q, want %q", storedFlow, AUTH_FLOW_WORKLOAD_IDENTITY)
}
if storedEmail != testEmail {
t.Errorf("SERVICE_ACCOUNT_EMAIL = %q, want %q", storedEmail, testEmail)
}
if storedKey != "" {
t.Errorf("SERVICE_ACCOUNT_KEY was written to storage in WIF mode, want empty")
t.Errorf("SERVICE_ACCOUNT_KEY = %q, want empty in WIF mode", storedKey)
}
storedPrivKey, _ := GetAuthField(PRIVATE_KEY)
if storedPrivKey != "" {
t.Errorf("PRIVATE_KEY was written to storage in WIF mode, want empty")
t.Errorf("PRIVATE_KEY = %q, want empty in WIF mode", storedPrivKey)
}
})
}
Expand Down
3 changes: 2 additions & 1 deletion internal/pkg/auth/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
AUTH_FLOW_USER_TOKEN AuthFlow = "user_token"
AUTH_FLOW_SERVICE_ACCOUNT_TOKEN AuthFlow = "sa_token"
AUTH_FLOW_SERVICE_ACCOUNT_KEY AuthFlow = "sa_key"
AUTH_FLOW_WORKLOAD_IDENTITY AuthFlow = "sa_wif"
)

// Returns all auth field keys managed by the auth storage
Expand Down Expand Up @@ -335,7 +336,7 @@ func GetProfileEmail(profile string) string {
if err != nil {
email = ""
}
case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY:
case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY, AUTH_FLOW_WORKLOAD_IDENTITY:
email, err = getAuthFieldWithProfile(profile, SERVICE_ACCOUNT_EMAIL)
if err != nil {
email = ""
Expand Down
Loading
Loading