Skip to content
Merged
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
2 changes: 2 additions & 0 deletions AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ $ stackit auth activate-service-account

You can also configure the service account credentials directly in the CLI. To get help and to get a list of the available options run the command with the `-h` flag.

**_Note:_** There is an optional flag `--only-print-access-token` which can be used to only obtain the access token which prevents writing the credentials to the keyring or into `cli-auth-storage.txt` ([File Location](./README.md#configuration)). This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands by default.

### Overview

If you don't have a service account, create one in the [STACKIT Portal](https://portal.stackit.cloud/) and assign the necessary permissions to it, e.g. `owner`. There are two ways to authenticate:
Expand Down
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 @@ -23,12 +23,16 @@ stackit auth activate-service-account [flags]

Activate service account authentication in the STACKIT CLI using the service account token
$ stackit auth activate-service-account --service-account-token my-service-account-token

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
```

### Options

```
-h, --help Help for "stackit auth activate-service-account"
--only-print-access-token If this is set to true the credentials are not stored in either the keyring or a file
--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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ const (
serviceAccountTokenFlag = "service-account-token"
serviceAccountKeyPathFlag = "service-account-key-path"
privateKeyPathFlag = "private-key-path"
onlyPrintAccessTokenFlag = "only-print-access-token" // #nosec G101
)

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

func NewCmd(p *print.Printer) *cobra.Command {
Expand All @@ -50,13 +52,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
examples.NewExample(
`Activate service account authentication in the STACKIT CLI using the service account token`,
"$ stackit auth activate-service-account --service-account-token my-service-account-token"),
examples.NewExample(
`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",
),
),
RunE: func(cmd *cobra.Command, _ []string) error {
model := parseInput(p, cmd)

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

cfg := &sdkConfig.Configuration{
Expand All @@ -75,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}

// Authenticates the service account and stores credentials
email, err := auth.AuthenticateServiceAccount(p, rt)
email, accessToken, err := auth.AuthenticateServiceAccount(p, rt, model.OnlyPrintAccessToken)
if err != nil {
var activateServiceAccountError *cliErr.ActivateServiceAccountError
if !errors.As(err, &activateServiceAccountError) {
Expand All @@ -84,8 +92,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
return err
}

p.Info("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email)

if model.OnlyPrintAccessToken {
// Only output is the access token
p.Outputf("%s\n", accessToken)
} else {
p.Outputf("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email)
}
return nil
},
}
Expand All @@ -97,13 +109,15 @@ 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(onlyPrintAccessTokenFlag, false, "If this is set to true the credentials are not stored in either the keyring or a file")
}

func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel {
model := inputModel{
ServiceAccountToken: flags.FlagToStringValue(p, cmd, serviceAccountTokenFlag),
ServiceAccountKeyPath: flags.FlagToStringValue(p, cmd, serviceAccountKeyPathFlag),
PrivateKeyPath: flags.FlagToStringValue(p, cmd, privateKeyPathFlag),
OnlyPrintAccessToken: flags.FlagToBoolValue(p, cmd, onlyPrintAccessTokenFlag),
}

if p.IsVerbosityDebug() {
Expand All @@ -118,12 +132,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel {
return &model
}

func storeFlags() (tokenCustomEndpoint string, err error) {
tokenCustomEndpoint = viper.GetString(config.TokenCustomEndpointKey)

err = auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint)
if err != nil {
return "", fmt.Errorf("set %s: %w", auth.TOKEN_CUSTOM_ENDPOINT, err)
}
return tokenCustomEndpoint, nil
func storeCustomEndpoint(tokenCustomEndpoint string) error {
return auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
serviceAccountTokenFlag: "token",
serviceAccountKeyPathFlag: "sa_key",
privateKeyPathFlag: "private_key",
onlyPrintAccessTokenFlag: "true",
}
for _, mod := range mods {
mod(flagValues)
Expand All @@ -32,6 +33,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
ServiceAccountToken: "token",
ServiceAccountKeyPath: "sa_key",
PrivateKeyPath: "private_key",
OnlyPrintAccessToken: true,
}
for _, mod := range mods {
mod(model)
Expand Down Expand Up @@ -87,6 +89,18 @@ func TestParseInput(t *testing.T) {
}),
isValid: false,
},
{
description: "default value OnlyPrintAccessToken",
flagValues: fixtureFlagValues(
func(flagValues map[string]string) {
delete(flagValues, "only-print-access-token")
},
),
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
model.OnlyPrintAccessToken = false
}),
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -121,7 +135,7 @@ func TestParseInput(t *testing.T) {
}
}

func TestStoreFlags(t *testing.T) {
func TestStoreCustomEndpointFlags(t *testing.T) {
tests := []struct {
description string
model *inputModel
Expand Down Expand Up @@ -154,7 +168,7 @@ func TestStoreFlags(t *testing.T) {
viper.Reset()
viper.Set(config.TokenCustomEndpointKey, tt.tokenCustomEndpoint)

tokenCustomEndpoint, err := storeFlags()
err := storeCustomEndpoint(tt.tokenCustomEndpoint)
if !tt.isValid {
if err == nil {
t.Fatalf("did not fail on invalid input")
Expand All @@ -169,8 +183,8 @@ func TestStoreFlags(t *testing.T) {
if err != nil {
t.Errorf("Failed to get value of auth field: %v", err)
}
if value != tokenCustomEndpoint {
t.Errorf("Value of \"%s\" does not match: expected \"%s\", got \"%s\"", auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint, value)
if value != tt.tokenCustomEndpoint {
t.Errorf("Value of \"%s\" does not match: expected \"%s\", got \"%s\"", auth.TOKEN_CUSTOM_ENDPOINT, tt.tokenCustomEndpoint, value)
}
})
}
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("authorization failed: %w", err)
}

p.Info("Successfully logged into STACKIT CLI.\n")
p.Outputln("Successfully logged into STACKIT CLI.\n")

return nil
},
}
Expand Down
9 changes: 9 additions & 0 deletions internal/pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"fmt"
"os"
"strconv"
"time"

Expand All @@ -22,7 +23,15 @@ type tokenClaims struct {
// It returns the configuration option that can be used to create an authenticated SDK client.
//
// If the user was logged in and the user session expired, reauthorizeUserRoutine is called to reauthenticate the user again.
// If the environment variable STACKIT_ACCESS_TOKEN is set this token is used instead.
func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, _ bool) error) (authCfgOption sdkConfig.ConfigurationOption, err error) {
// Get access token from env and use this if present
accessToken := os.Getenv(envAccessTokenName)
if accessToken != "" {
authCfgOption = sdkConfig.WithToken(accessToken)
return authCfgOption, nil
}

flow, err := GetAuthFlow()
if err != nil {
return nil, fmt.Errorf("get authentication flow: %w", err)
Expand Down
31 changes: 17 additions & 14 deletions internal/pkg/auth/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ var _ http.RoundTripper = &keyFlowWithStorage{}
// For the key flow, it fetches an access and refresh token from the Service Account API.
// For the token flow, it just stores the provided token and doesn't check if it is valid.
// It returns the email associated with the service account
func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email string, err error) {
// If disableWriting is set to true the credentials are not stored on disk (keyring, file).
func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableWriting bool) (email, accessToken string, err error) {
authFields := make(map[authFieldKey]string)
var authFlowType AuthFlow
switch flow := rt.(type) {
Expand All @@ -46,12 +47,12 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s
accessToken, err := flow.GetAccessToken()
if err != nil {
p.Debug(print.ErrorLevel, "get access token: %v", err)
return "", &errors.ActivateServiceAccountError{}
return "", "", &errors.ActivateServiceAccountError{}
}
serviceAccountKey := flow.GetConfig().ServiceAccountKey
saKeyBytes, err := json.Marshal(serviceAccountKey)
if err != nil {
return "", fmt.Errorf("marshal service account key: %w", err)
return "", "", fmt.Errorf("marshal service account key: %w", err)
}

authFields[ACCESS_TOKEN] = accessToken
Expand All @@ -64,12 +65,12 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s

authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken
default:
return "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue")
return "", "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue")
}

email, err = getEmailFromToken(authFields[ACCESS_TOKEN])
if err != nil {
return "", fmt.Errorf("get email from access token: %w", err)
return "", "", fmt.Errorf("get email from access token: %w", err)
}

p.Debug(print.DebugLevel, "successfully authenticated service account %s", email)
Expand All @@ -78,20 +79,22 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s

sessionExpiresAtUnix, err := getStartingSessionExpiresAtUnix()
if err != nil {
return "", fmt.Errorf("compute session expiration timestamp: %w", err)
return "", "", fmt.Errorf("compute session expiration timestamp: %w", err)
}
authFields[SESSION_EXPIRES_AT_UNIX] = sessionExpiresAtUnix

err = SetAuthFlow(authFlowType)
if err != nil {
return "", fmt.Errorf("set auth flow type: %w", err)
}
err = SetAuthFieldMap(authFields)
if err != nil {
return "", fmt.Errorf("set in auth storage: %w", err)
if !disableWriting {
err = SetAuthFlow(authFlowType)
if err != nil {
return "", "", fmt.Errorf("set auth flow type: %w", err)
}
err = SetAuthFieldMap(authFields)
if err != nil {
return "", "", fmt.Errorf("set in auth storage: %w", err)
}
}

return authFields[SERVICE_ACCOUNT_EMAIL], nil
return authFields[SERVICE_ACCOUNT_EMAIL], authFields[ACCESS_TOKEN], nil
}

// initKeyFlowWithStorage initializes the keyFlow from the SDK and creates a keyFlowWithStorage struct that uses that keyFlow
Expand Down
2 changes: 1 addition & 1 deletion internal/pkg/auth/service_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func TestAuthenticateServiceAccount(t *testing.T) {
}

p := print.NewPrinter()
email, err := AuthenticateServiceAccount(p, flow)
email, _, err := AuthenticateServiceAccount(p, flow, false)

if !tt.isValid {
if err == nil {
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/auth/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
keyringService = "stackit-cli"
textFileFolderName = "stackit"
textFileName = "cli-auth-storage.txt"
envAccessTokenName = "STACKIT_ACCESS_TOKEN"
)

const (
Expand Down