Skip to content

Commit 73ecae5

Browse files
committed
store the token
Signed-off-by: Jorge Turrado <jorge_turrado@hotmail.es>
1 parent 804fbab commit 73ecae5

8 files changed

Lines changed: 164 additions & 13 deletions

File tree

‎AUTHENTICATION.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,12 @@ Using this flow is less secure since the token is long-lived. You can provide th
133133
```
134134

135135
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.
136+
137+
3. Activate the service account and run commands as usual:
138+
139+
```bash
140+
stackit auth activate-service-account --use-oidc
141+
stackit project list
142+
```
143+
144+
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).

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ func runOIDCMode(params *types.CmdParams, model *inputModel) error {
165165
}
166166

167167
tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
168+
if !model.OnlyPrintAccessToken {
169+
if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
170+
return err
171+
}
172+
}
168173

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

182-
// credentials are never written to disk in OIDC mode
183-
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, true)
187+
// only the service account email is stored, the federated token is read from the environment on every command
188+
saEmail, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, model.OnlyPrintAccessToken)
184189
if err != nil {
185190
var activateErr *cliErr.ActivateServiceAccountError
186191
if !errors.As(err, &activateErr) {

‎internal/pkg/auth/auth.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
100100
return nil, fmt.Errorf("initialize service account key flow: %w", err)
101101
}
102102
authCfgOption = sdkConfig.WithCustomAuth(keyFlow)
103+
case AUTH_FLOW_WORKLOAD_IDENTITY:
104+
p.Debug(print.DebugLevel, "authenticating using workload identity federation")
105+
if userSessionExpired {
106+
return nil, fmt.Errorf("session expired")
107+
}
108+
wifFlow, err := initWIFFlowWithStorage()
109+
if err != nil {
110+
return nil, fmt.Errorf("initialize workload identity federation flow: %w", err)
111+
}
112+
authCfgOption = sdkConfig.WithCustomAuth(wifFlow)
103113
case AUTH_FLOW_USER_TOKEN:
104114
p.Debug(print.DebugLevel, "authenticating using user token")
105115
if userSessionExpired {
@@ -195,6 +205,15 @@ func GetValidAccessToken(p *print.Printer) (string, error) {
195205
return GetAccessToken()
196206
}
197207

208+
// For workload identity federation, request a new token if the stored one expired
209+
if flow == AUTH_FLOW_WORKLOAD_IDENTITY {
210+
wifFlow, err := initWIFFlowWithStorage()
211+
if err != nil {
212+
return "", fmt.Errorf("initialize workload identity federation flow: %w", err)
213+
}
214+
return wifFlow.GetAccessToken()
215+
}
216+
198217
if flow != AUTH_FLOW_USER_TOKEN {
199218
return "", fmt.Errorf("unsupported authentication flow: %s", flow)
200219
}

‎internal/pkg/auth/auth_test.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ func TestAuthenticationConfig(t *testing.T) {
7474
saKey string
7575
privateKeySet bool
7676
tokenEndpoint string
77+
saEmail string
78+
federatedToken string
7779
isValid bool
7880
expectedCustomAuthSet bool
7981
expectedTokenSet bool
@@ -119,6 +121,41 @@ func TestAuthenticationConfig(t *testing.T) {
119121
tokenEndpoint: "token_url",
120122
isValid: false,
121123
},
124+
{
125+
description: "base_workload_identity",
126+
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
127+
sessionExpiresAt: time.Now().Add(time.Hour),
128+
accessTokenSet: true,
129+
saEmail: "ci@sa.stackit.cloud",
130+
federatedToken: "federated_token",
131+
isValid: true,
132+
expectedCustomAuthSet: true,
133+
},
134+
{
135+
description: "workload_identity_session_expired",
136+
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
137+
sessionExpiresAt: time.Now().Add(-time.Hour),
138+
accessTokenSet: true,
139+
saEmail: "ci@sa.stackit.cloud",
140+
federatedToken: "federated_token",
141+
isValid: false,
142+
},
143+
{
144+
description: "workload_identity_no_federated_token_source",
145+
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
146+
sessionExpiresAt: time.Now().Add(time.Hour),
147+
accessTokenSet: true,
148+
saEmail: "ci@sa.stackit.cloud",
149+
isValid: false,
150+
},
151+
{
152+
description: "workload_identity_no_email",
153+
flow: AUTH_FLOW_WORKLOAD_IDENTITY,
154+
sessionExpiresAt: time.Now().Add(time.Hour),
155+
accessTokenSet: true,
156+
federatedToken: "federated_token",
157+
isValid: false,
158+
},
122159
{
123160
description: "base_user_token",
124161
flow: AUTH_FLOW_USER_TOKEN,
@@ -156,6 +193,13 @@ func TestAuthenticationConfig(t *testing.T) {
156193
for _, tt := range tests {
157194
t.Run(tt.description, func(t *testing.T) {
158195
t.Setenv(EnvUseOIDC, "") // ensure OIDC mode is off for these tests
196+
// only the static federated token is used as OIDC token source in these tests
197+
t.Setenv(EnvServiceAccountFederatedToken, tt.federatedToken)
198+
t.Setenv(EnvFederatedTokenFile, "")
199+
t.Setenv(EnvGitHubRequestURL, "")
200+
t.Setenv(EnvGitHubRequestToken, "")
201+
t.Setenv(EnvAzureOIDCRequestURI, "")
202+
t.Setenv(EnvAzureAccessToken, "")
159203
keyring.MockInit()
160204
timestamp := time.Now().Add(24 * time.Hour)
161205
authFields := make(map[authFieldKey]string)
@@ -182,6 +226,7 @@ func TestAuthenticationConfig(t *testing.T) {
182226
authFields[REFRESH_TOKEN] = tt.refreshToken
183227
authFields[SERVICE_ACCOUNT_KEY] = tt.saKey
184228
authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint
229+
authFields[SERVICE_ACCOUNT_EMAIL] = tt.saEmail
185230

186231
err = SetAuthFlow(tt.flow)
187232
if err != nil {

‎internal/pkg/auth/service_account.go‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,17 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW
6868
authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken
6969
case wifFlowInterface:
7070
p.Debug(print.DebugLevel, "authenticating using workload identity federation")
71-
authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_KEY
71+
authFlowType = AUTH_FLOW_WORKLOAD_IDENTITY
7272

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

154+
// initWIFFlowWithStorage initializes the workload identity federation flow for the service account stored in the auth storage.
155+
// The federated token is not stored, it is read from the environment every time the access token is refreshed.
156+
func initWIFFlowWithStorage() (*clients.WorkloadIdentityFederationFlow, error) {
157+
authFields := map[authFieldKey]string{
158+
SERVICE_ACCOUNT_EMAIL: "",
159+
TOKEN_CUSTOM_ENDPOINT: "",
160+
}
161+
err := GetAuthFieldMap(authFields)
162+
if err != nil {
163+
return nil, fmt.Errorf("get from auth storage: %w", err)
164+
}
165+
166+
tokenFunc, err := OIDCTokenFunc()
167+
if err != nil {
168+
return nil, err
169+
}
170+
171+
cfg := &clients.WorkloadIdentityFederationFlowConfig{
172+
TokenUrl: authFields[TOKEN_CUSTOM_ENDPOINT],
173+
ClientID: authFields[SERVICE_ACCOUNT_EMAIL],
174+
FederatedTokenFunction: tokenFunc,
175+
}
176+
177+
wifFlow := &clients.WorkloadIdentityFederationFlow{}
178+
err = wifFlow.Init(cfg)
179+
if err != nil {
180+
return nil, fmt.Errorf("initialize workload identity federation flow: %w", err)
181+
}
182+
return wifFlow, nil
183+
}
184+
152185
// The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access token
153186
func (kf *keyFlowWithStorage) RoundTrip(req *http.Request) (*http.Response, error) {
154187
resp, err := kf.keyFlow.RoundTrip(req)

‎internal/pkg/auth/service_account_test.go‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,15 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
207207
isValid bool
208208
}{
209209
{
210-
description: "wif_success_no_credentials_written",
210+
description: "wif_success_credentials_written",
211211
accessToken: raw,
212-
disableWriting: false, // even when false, WIF forces no-write internally
212+
disableWriting: false,
213+
isValid: true,
214+
},
215+
{
216+
description: "wif_success_writing_disabled",
217+
accessToken: raw,
218+
disableWriting: true,
213219
isValid: true,
214220
},
215221
{
@@ -224,6 +230,17 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
224230
keyring.MockInit()
225231
config.InitConfig()
226232

233+
// leftovers from a previous key flow must not survive a WIF activation
234+
if err := SetAuthFieldMap(map[authFieldKey]string{
235+
SERVICE_ACCOUNT_KEY: "old_key",
236+
PRIVATE_KEY: "old_private_key",
237+
}); err != nil {
238+
t.Fatalf("set previous credentials: %v", err)
239+
}
240+
if err := SetAuthFlow(AUTH_FLOW_SERVICE_ACCOUNT_KEY); err != nil {
241+
t.Fatalf("set previous auth flow: %v", err)
242+
}
243+
227244
flow := &wifFlowMocked{
228245
accessToken: tt.accessToken,
229246
getAccessTokenFail: tt.getAccessTokenFail,
@@ -245,16 +262,31 @@ func TestAuthenticateServiceAccount_WIF(t *testing.T) {
245262
t.Fatalf("email = %q, want %q", email, testEmail)
246263
}
247264

248-
// Verify no credentials were written to the keyring / file.
249-
// After a WIF authentication, the auth storage should not contain a
250-
// service account key or private key.
265+
storedFlow, _ := GetAuthFlow()
266+
storedEmail, _ := GetAuthField(SERVICE_ACCOUNT_EMAIL)
251267
storedKey, _ := GetAuthField(SERVICE_ACCOUNT_KEY)
268+
storedPrivKey, _ := GetAuthField(PRIVATE_KEY)
269+
270+
if tt.disableWriting {
271+
if storedFlow != AUTH_FLOW_SERVICE_ACCOUNT_KEY {
272+
t.Errorf("auth flow = %q, want previous flow %q untouched when writing is disabled", storedFlow, AUTH_FLOW_SERVICE_ACCOUNT_KEY)
273+
}
274+
return
275+
}
276+
277+
// Only the flow and the service account email are needed to rebuild the WIF flow,
278+
// the federated token is read from the environment on every command.
279+
if storedFlow != AUTH_FLOW_WORKLOAD_IDENTITY {
280+
t.Errorf("auth flow = %q, want %q", storedFlow, AUTH_FLOW_WORKLOAD_IDENTITY)
281+
}
282+
if storedEmail != testEmail {
283+
t.Errorf("SERVICE_ACCOUNT_EMAIL = %q, want %q", storedEmail, testEmail)
284+
}
252285
if storedKey != "" {
253-
t.Errorf("SERVICE_ACCOUNT_KEY was written to storage in WIF mode, want empty")
286+
t.Errorf("SERVICE_ACCOUNT_KEY = %q, want empty in WIF mode", storedKey)
254287
}
255-
storedPrivKey, _ := GetAuthField(PRIVATE_KEY)
256288
if storedPrivKey != "" {
257-
t.Errorf("PRIVATE_KEY was written to storage in WIF mode, want empty")
289+
t.Errorf("PRIVATE_KEY = %q, want empty in WIF mode", storedPrivKey)
258290
}
259291
})
260292
}

‎internal/pkg/auth/storage.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const (
4646
AUTH_FLOW_USER_TOKEN AuthFlow = "user_token"
4747
AUTH_FLOW_SERVICE_ACCOUNT_TOKEN AuthFlow = "sa_token"
4848
AUTH_FLOW_SERVICE_ACCOUNT_KEY AuthFlow = "sa_key"
49+
AUTH_FLOW_WORKLOAD_IDENTITY AuthFlow = "sa_wif"
4950
)
5051

5152
// Returns all auth field keys managed by the auth storage
@@ -335,7 +336,7 @@ func GetProfileEmail(profile string) string {
335336
if err != nil {
336337
email = ""
337338
}
338-
case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY:
339+
case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, AUTH_FLOW_SERVICE_ACCOUNT_KEY, AUTH_FLOW_WORKLOAD_IDENTITY:
339340
email, err = getAuthFieldWithProfile(profile, SERVICE_ACCOUNT_EMAIL)
340341
if err != nil {
341342
email = ""

‎internal/pkg/auth/storage_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,13 @@ func TestGetProfileEmail(t *testing.T) {
952952
authFlow: AUTH_FLOW_SERVICE_ACCOUNT_KEY,
953953
expectedEmail: "test@test.com",
954954
},
955+
{
956+
description: "default profile, workload identity",
957+
activeProfile: config.DefaultProfileName,
958+
serviceAccEmail: "test@test.com",
959+
authFlow: AUTH_FLOW_WORKLOAD_IDENTITY,
960+
expectedEmail: "test@test.com",
961+
},
955962
{
956963
description: "custom profile, user token",
957964
activeProfile: "test-profile",

0 commit comments

Comments
 (0)