Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Install STACKIT CLI
uses: jkroepke/setup-stackit-cli@v1
- name: Test
run: make test

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Release (2025-XX-YY)
- `core`: [v0.17.2](core/CHANGELOG.md#v0172-2025-XX-YY)
- **New:** Add STACKIT CLI auth flow.

## Release (2025-05-09)
- `resourcemanager`:
- [v0.13.3](services/resourcemanager/CHANGELOG.md#v0133-2025-05-09)
Expand Down
3 changes: 3 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v0.17.2 (2025-XX-YY)
- **New:** Add STACKIT CLI auth flow.

## v0.17.1 (2025-04-09)
- **Improvement:** Improve error message for key flow authentication

Expand Down
29 changes: 28 additions & 1 deletion core/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.DisableCLIAuthFlow {
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
Expand Down Expand Up @@ -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 == "" {
Expand Down
31 changes: 31 additions & 0 deletions core/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
Expand All @@ -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() {
Expand Down Expand Up @@ -150,6 +154,7 @@ func TestSetupAuth(t *testing.T) {
setKeyPaths bool
setCredentialsFilePathToken bool
setCredentialsFilePathKey bool
setCLIToken bool
isValid bool
}{
{
Expand Down Expand Up @@ -189,6 +194,13 @@ func TestSetupAuth(t *testing.T) {
setCredentialsFilePathToken: true,
isValid: true,
},
{
desc: "cli auth",
config: nil,
setCredentialsFilePathToken: false,
setCLIToken: true,
isValid: true,
},
{
desc: "custom_config_token",
config: &config.Configuration{
Expand Down Expand Up @@ -240,6 +252,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)
Expand Down
78 changes: 78 additions & 0 deletions core/clients/stackit_cli_flow.go
Original file line number Diff line number Diff line change
@@ -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
}
166 changes: 166 additions & 0 deletions core/clients/stackit_cli_flow_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading