diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 06882911..4bd13cdc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,18 @@ jobs: uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} + - name: Install STACKIT CLI + run: |- + OS=$(uname | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + [ "$ARCH" = "x86_64" ] && ARCH=amd64 + [ "$ARCH" = "aarch64" ] && ARCH=amd64 + + echo Downloading stackit-cli from https://github.com/stackitcloud/stackit-cli/releases/download/v${STACKIT_CLI_VERSION}/stackit-cli_${STACKIT_CLI_VERSION}_${OS}_${ARCH}.tar.gz + curl -sSfLo - https://github.com/stackitcloud/stackit-cli/releases/download/v${STACKIT_CLI_VERSION}/stackit-cli_${STACKIT_CLI_VERSION}_${OS}_${ARCH}.tar.gz | sudo tar zxf - -C /usr/local/bin/ stackit + env: + STACKIT_CLI_VERSION: 0.31.0 - name: Test run: make test diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e781a1..b58dbf2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Release (2025-05-21) +- `core`: [v0.17.2](core/CHANGELOG.md#v0172-2025-05-21) + - **New:** Add STACKIT CLI auth flow. + ## Release (2025-05-15) - `alb`: - [v0.4.0](services/alb/CHANGELOG.md#v040-2025-05-15) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index c3823732..da18c217 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,3 +1,6 @@ +## v0.17.2 (2025-05-21) +- **New:** Add STACKIT CLI auth flow. + ## v0.17.1 (2025-04-09) - **Improvement:** Improve error message for key flow authentication diff --git a/core/auth/auth.go b/core/auth/auth.go index 7da6c968..3a7c4f87 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "errors" "fmt" "net/http" "os" @@ -63,6 +64,7 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { } return tokenRoundTripper, nil } + authRoundTripper, err := DefaultAuth(cfg) if err != nil { return nil, fmt.Errorf("configuring default authentication: %w", err) @@ -90,7 +92,17 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) { // Token flow rt, err = TokenAuth(cfg) if err != nil { - return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %w", keyFlowErr.Error(), err) + tokenFlowErr := err + if !cfg.CLIAuthFlow { + err = errors.New("CLI flow disabled") + } else { + // Stackit CLI flow + rt, err = StackitCliAuth(cfg) + } + + if err != nil { + return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %s, trying stackit cli flow: %w", keyFlowErr.Error(), tokenFlowErr.Error(), err) + } } } return rt, nil @@ -216,6 +228,21 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) { return client, nil } +// StackitCliAuth configures the [clients.STACKITCLIFlow] and returns an http.RoundTripper +func StackitCliAuth(cfg *config.Configuration) (http.RoundTripper, error) { + cliCfg := clients.STACKITCLIFlowConfig{} + if cfg.HTTPClient != nil && cfg.HTTPClient.Transport != nil { + cliCfg.HTTPTransport = cfg.HTTPClient.Transport + } + + client := &clients.STACKITCLIFlow{} + if err := client.Init(&cliCfg); err != nil { + return nil, fmt.Errorf("error initializing client: %w", err) + } + + return client, nil +} + // readCredentialsFile reads the credentials file from the specified path and returns Credentials func readCredentialsFile(path string) (*Credentials, error) { if path == "" { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 413399bd..8dc97d63 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -1,6 +1,7 @@ package auth import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -17,6 +18,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" ) +//nolint:gosec // testServiceAccountToken is a test token +const testServiceAccountToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImR1bW15QGV4YW1wbGUuY29tIiwiZXhwIjo5MDA3MTkyNTQ3NDA5OTF9.sM2yd5GL9kK4h8IKHbr_fA2XmrzEsLOeLTIPrU0VfMg" + func setTemporaryHome(t *testing.T) { old := userHomeDir t.Cleanup(func() { @@ -150,6 +154,7 @@ func TestSetupAuth(t *testing.T) { setKeyPaths bool setCredentialsFilePathToken bool setCredentialsFilePathKey bool + setCLIToken bool isValid bool }{ { @@ -189,6 +194,15 @@ func TestSetupAuth(t *testing.T) { setCredentialsFilePathToken: true, isValid: true, }, + { + desc: "cli auth", + config: &config.Configuration{ + CLIAuthFlow: true, + }, + setCredentialsFilePathToken: false, + setCLIToken: true, + isValid: true, + }, { desc: "custom_config_token", config: &config.Configuration{ @@ -240,6 +254,25 @@ func TestSetupAuth(t *testing.T) { t.Setenv("STACKIT_CREDENTIALS_PATH", "") } + if test.setCLIToken { + _, _ = clients.RunSTACKITCLICommand(context.TODO(), "stackit config profile delete test-setup-auth -y") + _, err := clients.RunSTACKITCLICommand(context.TODO(), "stackit config profile create test-setup-auth") + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + + _, err = clients.RunSTACKITCLICommand(context.TODO(), "stackit auth activate-service-account --service-account-token="+testServiceAccountToken) + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + + defer func() { + _, _ = clients.RunSTACKITCLICommand(context.TODO(), "stackit config profile delete test-setup-auth -y") + }() + } + t.Setenv("STACKIT_SERVICE_ACCOUNT_EMAIL", "test-email") authRoundTripper, err := SetupAuth(test.config) diff --git a/core/clients/stackit_cli_flow.go b/core/clients/stackit_cli_flow.go new file mode 100644 index 00000000..6006fae2 --- /dev/null +++ b/core/clients/stackit_cli_flow.go @@ -0,0 +1,78 @@ +package clients + +import ( + "bytes" + "context" + "errors" + "net/http" + "os" + "os/exec" + "runtime" + "strings" +) + +// STACKITCLIFlow invoke the STACKIT CLI from PATH to get the access token. +// If successful, then token is passed to clients.TokenFlow. +type STACKITCLIFlow struct { + TokenFlow +} + +// STACKITCLIFlowConfig is the flow config +type STACKITCLIFlowConfig struct { + HTTPTransport http.RoundTripper +} + +// GetConfig returns the flow configuration +func (c *STACKITCLIFlow) GetConfig() STACKITCLIFlowConfig { + return STACKITCLIFlowConfig{} +} + +func (c *STACKITCLIFlow) Init(cfg *STACKITCLIFlowConfig) error { + token, err := c.getTokenFromCLI() + if err != nil { + return err + } + + return c.TokenFlow.Init(&TokenFlowConfig{ + ServiceAccountToken: strings.TrimSpace(token), + HTTPTransport: cfg.HTTPTransport, + }) +} + +func (c *STACKITCLIFlow) getTokenFromCLI() (string, error) { + return RunSTACKITCLICommand(context.TODO(), "stackit auth get-access-token") +} + +// RunSTACKITCLICommand executes the command line and returns the output. +func RunSTACKITCLICommand(ctx context.Context, commandLine string) (string, error) { + var cliCmd *exec.Cmd + if runtime.GOOS == "windows" { + dir := os.Getenv("SYSTEMROOT") + if dir == "" { + return "", errors.New("environment variable 'SYSTEMROOT' has no value") + } + cliCmd = exec.CommandContext(ctx, "cmd.exe", "/c", commandLine) + cliCmd.Dir = dir + } else { + cliCmd = exec.CommandContext(ctx, "/bin/sh", "-c", commandLine) + cliCmd.Dir = "/bin" + } + cliCmd.Env = os.Environ() + var stderr bytes.Buffer + cliCmd.Stderr = &stderr + + output, err := cliCmd.Output() + if err != nil { + msg := stderr.String() + var exErr *exec.ExitError + if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'stackit' is not recognized") { + msg = "STACKIT CLI not found on path" + } + if msg == "" { + msg = err.Error() + } + return "", errors.New(msg) + } + + return string(output), nil +} diff --git a/core/clients/stackit_cli_flow_test.go b/core/clients/stackit_cli_flow_test.go new file mode 100644 index 00000000..e0c708e5 --- /dev/null +++ b/core/clients/stackit_cli_flow_test.go @@ -0,0 +1,166 @@ +package clients + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +//nolint:gosec // testServiceAccountToken is a test token +const testServiceAccountToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImR1bW15QGV4YW1wbGUuY29tIiwiZXhwIjo5MDA3MTkyNTQ3NDA5OTF9.sM2yd5GL9kK4h8IKHbr_fA2XmrzEsLOeLTIPrU0VfMg" + +func TestSTACKITCLIFlow_Init(t *testing.T) { + type args struct { + cfg *STACKITCLIFlowConfig + confFn func(t *testing.T) + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"ok", args{ + cfg: &STACKITCLIFlowConfig{}, + confFn: func(t *testing.T) { + _, err := RunSTACKITCLICommand(context.TODO(), "stackit auth activate-service-account --service-account-token="+testServiceAccountToken) + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + }, + }, false}, + {"no-token", args{ + cfg: &STACKITCLIFlowConfig{}, + confFn: func(_ *testing.T) {}, + }, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + + c := &STACKITCLIFlow{} + + cliProfileName := "test-stackit-cli-flow-init" + tt.name + + _, _ = RunSTACKITCLICommand(ctx, fmt.Sprintf("stackit config profile delete %s -y", cliProfileName)) + _, err := RunSTACKITCLICommand(ctx, "stackit config profile create "+cliProfileName) + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + + tt.args.confFn(t) + + defer func() { + _, _ = RunSTACKITCLICommand(ctx, fmt.Sprintf("stackit config profile delete %s -y", cliProfileName)) + }() + + if err := c.Init(tt.args.cfg); err != nil { + if (err != nil) != tt.wantErr { + t.Errorf("TokenFlow.Init() error = %v, wantErr %v", err, tt.wantErr) + } + + return + } + + if c.config == nil { + t.Error("config is nil") + } + }) + } +} + +func TestSTACKITCLIFlow_Do(t *testing.T) { + type fields struct { + client *http.Client + config *STACKITCLIFlowConfig + } + type args struct{} + tests := []struct { + name string + fields fields + args args + want int + wantErr bool + }{ + {"success", fields{&http.Client{}, &STACKITCLIFlowConfig{}}, args{}, http.StatusOK, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + + _, _ = RunSTACKITCLICommand(ctx, "stackit config profile delete test-stackit-cli-flow-do -y") + _, err := RunSTACKITCLICommand(ctx, "stackit config profile create test-stackit-cli-flow-do") + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + + _, err = RunSTACKITCLICommand(ctx, "stackit auth activate-service-account --service-account-token="+testServiceAccountToken) + if err != nil { + t.Errorf("RunSTACKITCLICommand() error = %v", err) + return + } + + defer func() { + _, _ = RunSTACKITCLICommand(ctx, "stackit config profile delete test-stackit-cli-flow-do -y") + }() + + c := &STACKITCLIFlow{} + err = c.Init(tt.fields.config) + if err != nil { + t.Errorf("Init() error = %v", err) + return + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization := r.Header.Get("Authorization") + if authorization != "Bearer "+testServiceAccountToken { + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprintln(w, `{"error":"missing authorization header"}`) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, `{"status":"ok"}`) + }) + server := httptest.NewServer(handler) + defer server.Close() + + u, err := url.Parse(server.URL) + if err != nil { + t.Error(err) + return + } + req, err := http.NewRequest(http.MethodGet, u.String(), http.NoBody) + if err != nil { + t.Error(err) + return + } + got, err := c.RoundTrip(req) + if err == nil { + // Defer discard and close the body + defer func() { + if _, discardErr := io.Copy(io.Discard, got.Body); discardErr != nil && err == nil { + err = discardErr + } + if closeErr := got.Body.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() + } + if (err != nil) != tt.wantErr { + t.Errorf("STACKITCLIFlow.Do() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != nil && got.StatusCode != tt.want { + t.Errorf("STACKITCLIFlow.Do() = %v, want %v", got.StatusCode, tt.want) + } + }) + } +} diff --git a/core/config/config.go b/core/config/config.go index 93002c02..3668453c 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -90,6 +90,7 @@ type Configuration struct { CredentialsFilePath string `json:"credentialsFilePath,omitempty"` TokenCustomUrl string `json:"tokenCustomUrl,omitempty"` Region string `json:"region,omitempty"` + CLIAuthFlow bool `json:"cliAuthFlow,omitempty"` CustomAuth http.RoundTripper Servers ServerConfigurations OperationServers map[string]ServerConfigurations