From ab2d33aa2841141b8eddb853b83e80cb12a466e3 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Thu, 3 Apr 2025 15:13:01 +0200 Subject: [PATCH] feat(auth): Introduce possibility to use an environment variable to authenticate If STACKIT_ACCESS_TOKEN is set this environment variable is used instead of stored tokens. Additionally the activate-service-account command is extended in order to only print the token but not store them in the keyring or in a file on the disk. Signed-off-by: Alexander Dahmen --- AUTHENTICATION.md | 2 ++ docs/stackit_auth_activate-service-account.md | 4 +++ .../activate_service_account.go | 36 +++++++++++-------- .../activate_service_account_test.go | 22 +++++++++--- internal/cmd/auth/login/login.go | 3 +- internal/pkg/auth/auth.go | 9 +++++ internal/pkg/auth/service_account.go | 31 ++++++++-------- internal/pkg/auth/service_account_test.go | 2 +- internal/pkg/auth/storage.go | 1 + 9 files changed, 76 insertions(+), 34 deletions(-) diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index f88cb3883..1ec7ea3ba 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -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: diff --git a/docs/stackit_auth_activate-service-account.md b/docs/stackit_auth_activate-service-account.md index 8e8b7ac81..3d154ebfb 100644 --- a/docs/stackit_auth_activate-service-account.md +++ b/docs/stackit_auth_activate-service-account.md @@ -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 diff --git a/internal/cmd/auth/activate-service-account/activate_service_account.go b/internal/cmd/auth/activate-service-account/activate_service_account.go index 82c549a5d..46922720a 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -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 { @@ -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{ @@ -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) { @@ -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 }, } @@ -97,6 +109,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(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 { @@ -104,6 +117,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *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() { @@ -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) } diff --git a/internal/cmd/auth/activate-service-account/activate_service_account_test.go b/internal/cmd/auth/activate-service-account/activate_service_account_test.go index 854022fd4..9dcbb22b7 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account_test.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account_test.go @@ -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) @@ -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) @@ -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 { @@ -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 @@ -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") @@ -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) } }) } diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 9c17acef3..06531f706 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -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 }, } diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index fdd64354d..89a39ac29 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "os" "strconv" "time" @@ -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) diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go index 62e154bad..1f1b01729 100644 --- a/internal/pkg/auth/service_account.go +++ b/internal/pkg/auth/service_account.go @@ -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) { @@ -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 @@ -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) @@ -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 diff --git a/internal/pkg/auth/service_account_test.go b/internal/pkg/auth/service_account_test.go index a4b3a72ce..adc0f8bc5 100644 --- a/internal/pkg/auth/service_account_test.go +++ b/internal/pkg/auth/service_account_test.go @@ -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 { diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 02f397386..7b6901424 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -25,6 +25,7 @@ const ( keyringService = "stackit-cli" textFileFolderName = "stackit" textFileName = "cli-auth-storage.txt" + envAccessTokenName = "STACKIT_ACCESS_TOKEN" ) const (