Skip to content

Commit 8b757b1

Browse files
committed
core: Add STACKIT CLI Auth flow
Signed-off-by: Jan-Otto Kröpke <[email protected]>
1 parent d8bffae commit 8b757b1

File tree

7 files changed

+260
-1
lines changed

7 files changed

+260
-1
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ jobs:
1717
uses: actions/setup-go@v5
1818
with:
1919
go-version: ${{ matrix.go-version }}
20+
- name: Install STACKIT CLI
21+
uses: jkroepke/setup-stackit-cli@v1
2022
- name: Test
2123
run: make test
2224

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Release (2025-XX-YY)
2+
- `core`: [v0.16.2](core/CHANGELOG.md#v0162-2025-XX-YY)
3+
- **New:** If a custom http.Client is provided, the http.Transport is respected. This allows customizing the http.Client with custom timeouts or instrumentation.
4+
15
## Release (2025-03-14)
26
- `certificates`: [v1.0.0](services/certificates/CHANGELOG.md#v100-2025-03-14)
37
- **Breaking Change:** The region is no longer specified within the client configuration. Instead, the region must be passed as a parameter to any region-specific request.

core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## v0.16.2 (2025-XX-YY)
2+
3+
- **New:** Add STACKIT CLI auth flow.
4+
15
## v0.16.1 (2025-02-25)
26

37
- **Bugfix:** STACKIT_PRIVATE_KEY and STACKIT_SERVICE_ACCOUNT_KEY can be set via environment variable or via credentials file.

core/auth/auth.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"net/http"
78
"os"
@@ -62,7 +63,14 @@ func SetupAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {
6263
return nil, fmt.Errorf("configuring token authentication: %w", err)
6364
}
6465
return tokenRoundTripper, nil
66+
} else if !cfg.DisableCLIAuthFlow {
67+
cliRoundTripper, err := StackitCliAuth(cfg)
68+
if err != nil {
69+
return nil, fmt.Errorf("configuring CLI authentication: %w", err)
70+
}
71+
return cliRoundTripper, nil
6572
}
73+
6674
authRoundTripper, err := DefaultAuth(cfg)
6775
if err != nil {
6876
return nil, fmt.Errorf("configuring default authentication: %w", err)
@@ -90,7 +98,17 @@ func DefaultAuth(cfg *config.Configuration) (rt http.RoundTripper, err error) {
9098
// Token flow
9199
rt, err = TokenAuth(cfg)
92100
if err != nil {
93-
return nil, fmt.Errorf("no valid credentials were found: trying key flow: %s, trying token flow: %w", keyFlowErr.Error(), err)
101+
tokenFlowErr := err
102+
if cfg.DisableCLIAuthFlow {
103+
err = errors.New("CLI flow disabled")
104+
} else {
105+
// Stackit CLI flow
106+
rt, err = StackitCliAuth(cfg)
107+
}
108+
109+
if err != nil {
110+
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)
111+
}
94112
}
95113
}
96114
return rt, nil
@@ -195,6 +213,16 @@ func KeyAuth(cfg *config.Configuration) (http.RoundTripper, error) {
195213
return client, nil
196214
}
197215

216+
// StackitCliAuth configures the [clients.STACKITCLIFlow] and returns an http.RoundTripper
217+
func StackitCliAuth(_ *config.Configuration) (http.RoundTripper, error) {
218+
client := &clients.STACKITCLIFlow{}
219+
if err := client.Init(&clients.STACKITCLIFlowConfig{}); err != nil {
220+
return nil, fmt.Errorf("error initializing client: %w", err)
221+
}
222+
223+
return client, nil
224+
}
225+
198226
// readCredentialsFile reads the credentials file from the specified path and returns Credentials
199227
func readCredentialsFile(path string) (*Credentials, error) {
200228
if path == "" {

core/clients/stackit_cli_flow.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package clients
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"os"
8+
"os/exec"
9+
"runtime"
10+
"strings"
11+
)
12+
13+
// STACKITCLIFlow invoke the STACKIT CLI from PATH to get the access token.
14+
// If successful, then token is passed to clients.TokenFlow.
15+
type STACKITCLIFlow struct {
16+
TokenFlow
17+
}
18+
19+
// STACKITCLIFlowConfig is the flow config
20+
type STACKITCLIFlowConfig struct{}
21+
22+
// GetConfig returns the flow configuration
23+
func (c *STACKITCLIFlow) GetConfig() STACKITCLIFlowConfig {
24+
return STACKITCLIFlowConfig{}
25+
}
26+
27+
func (c *STACKITCLIFlow) Init(_ *STACKITCLIFlowConfig) error {
28+
token, err := c.getTokenFromCLI()
29+
if err != nil {
30+
return err
31+
}
32+
33+
c.config = &TokenFlowConfig{
34+
ServiceAccountToken: strings.TrimSpace(token),
35+
}
36+
37+
c.configureHTTPClient()
38+
return c.validate()
39+
}
40+
41+
func (c *STACKITCLIFlow) getTokenFromCLI() (string, error) {
42+
return runSTACKITCLICommand(context.TODO(), "stackit auth get-access-token")
43+
}
44+
45+
// runSTACKITCLICommand executes the command line and returns the output.
46+
func runSTACKITCLICommand(ctx context.Context, commandLine string) (string, error) {
47+
var cliCmd *exec.Cmd
48+
if runtime.GOOS == "windows" {
49+
dir := os.Getenv("SYSTEMROOT")
50+
if dir == "" {
51+
return "", errors.New("environment variable 'SYSTEMROOT' has no value")
52+
}
53+
cliCmd = exec.CommandContext(ctx, "cmd.exe", "/c", commandLine)
54+
cliCmd.Dir = dir
55+
} else {
56+
cliCmd = exec.CommandContext(ctx, "/bin/sh", "-c", commandLine)
57+
cliCmd.Dir = "/bin"
58+
}
59+
cliCmd.Env = os.Environ()
60+
var stderr bytes.Buffer
61+
cliCmd.Stderr = &stderr
62+
63+
output, err := cliCmd.Output()
64+
if err != nil {
65+
msg := stderr.String()
66+
var exErr *exec.ExitError
67+
if errors.As(err, &exErr) && exErr.ExitCode() == 127 || strings.HasPrefix(msg, "'stackit' is not recognized") {
68+
msg = "STACKIT CLI not found on path"
69+
}
70+
if msg == "" {
71+
msg = err.Error()
72+
}
73+
return "", errors.New(msg)
74+
}
75+
76+
return string(output), nil
77+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package clients
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
)
11+
12+
const testServiceAccountToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImR1bW15QGV4YW1wbGUuY29tIiwiZXhwIjo5MDA3MTkyNTQ3NDA5OTF9.sM2yd5GL9kK4h8IKHbr_fA2XmrzEsLOeLTIPrU0VfMg"
13+
14+
func TestSTACKITCLIFlow_Init(t *testing.T) {
15+
type args struct {
16+
cfg *STACKITCLIFlowConfig
17+
}
18+
tests := []struct {
19+
name string
20+
args args
21+
wantErr bool
22+
}{
23+
{"ok", args{&STACKITCLIFlowConfig{}}, false},
24+
}
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
c := &STACKITCLIFlow{}
28+
29+
_, _ = runSTACKITCLICommand(t.Context(), "stackit config profile delete test-stackit-cli-flow-init -y")
30+
_, err := runSTACKITCLICommand(t.Context(), "stackit config profile create test-stackit-cli-flow-init")
31+
if err != nil {
32+
t.Errorf("runSTACKITCLICommand() error = %v", err)
33+
return
34+
}
35+
36+
_, err = runSTACKITCLICommand(t.Context(), "stackit auth activate-service-account --service-account-token="+testServiceAccountToken)
37+
if err != nil {
38+
t.Errorf("runSTACKITCLICommand() error = %v", err)
39+
return
40+
}
41+
42+
defer func() {
43+
_, _ = runSTACKITCLICommand(t.Context(), "stackit config profile delete test-stackit-cli-flow-init -y")
44+
}()
45+
46+
if err := c.Init(tt.args.cfg); (err != nil) != tt.wantErr {
47+
t.Errorf("TokenFlow.Init() error = %v, wantErr %v", err, tt.wantErr)
48+
}
49+
if c.config == nil {
50+
t.Error("config is nil")
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestSTACKITCLIFlow_Do(t *testing.T) {
57+
type fields struct {
58+
client *http.Client
59+
config *STACKITCLIFlowConfig
60+
}
61+
type args struct{}
62+
tests := []struct {
63+
name string
64+
fields fields
65+
args args
66+
want int
67+
wantErr bool
68+
}{
69+
{"success", fields{&http.Client{}, &STACKITCLIFlowConfig{}}, args{}, http.StatusOK, false},
70+
}
71+
for _, tt := range tests {
72+
t.Run(tt.name, func(t *testing.T) {
73+
_, _ = runSTACKITCLICommand(t.Context(), "stackit config profile delete test-stackit-cli-flow-do -y")
74+
_, err := runSTACKITCLICommand(t.Context(), "stackit config profile create test-stackit-cli-flow-do")
75+
if err != nil {
76+
t.Errorf("runSTACKITCLICommand() error = %v", err)
77+
return
78+
}
79+
80+
_, err = runSTACKITCLICommand(t.Context(), "stackit auth activate-service-account --service-account-token="+testServiceAccountToken)
81+
if err != nil {
82+
t.Errorf("runSTACKITCLICommand() error = %v", err)
83+
return
84+
}
85+
86+
defer func() {
87+
_, _ = runSTACKITCLICommand(t.Context(), "stackit config profile delete test-stackit-cli-flow-do -y")
88+
}()
89+
90+
c := &STACKITCLIFlow{}
91+
err = c.Init(tt.fields.config)
92+
if err != nil {
93+
t.Errorf("Init() error = %v", err)
94+
return
95+
}
96+
97+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98+
authorization := r.Header.Get("Authorization")
99+
if authorization != "Bearer "+testServiceAccountToken {
100+
w.WriteHeader(http.StatusUnauthorized)
101+
_, _ = fmt.Fprintln(w, `{"error":"missing authorization header"}`)
102+
return
103+
}
104+
105+
w.Header().Set("Content-Type", "application/json")
106+
w.WriteHeader(http.StatusOK)
107+
_, _ = fmt.Fprintln(w, `{"status":"ok"}`)
108+
})
109+
server := httptest.NewServer(handler)
110+
defer server.Close()
111+
112+
u, err := url.Parse(server.URL)
113+
if err != nil {
114+
t.Error(err)
115+
return
116+
}
117+
req, err := http.NewRequest(http.MethodGet, u.String(), http.NoBody)
118+
if err != nil {
119+
t.Error(err)
120+
return
121+
}
122+
got, err := c.RoundTrip(req)
123+
if err == nil {
124+
// Defer discard and close the body
125+
defer func() {
126+
if _, discardErr := io.Copy(io.Discard, got.Body); discardErr != nil && err == nil {
127+
err = discardErr
128+
}
129+
if closeErr := got.Body.Close(); closeErr != nil && err == nil {
130+
err = closeErr
131+
}
132+
}()
133+
}
134+
if (err != nil) != tt.wantErr {
135+
t.Errorf("STACKITCLIFlow.Do() error = %v, wantErr %v", err, tt.wantErr)
136+
return
137+
}
138+
if got != nil && got.StatusCode != tt.want {
139+
t.Errorf("STACKITCLIFlow.Do() = %v, want %v", got.StatusCode, tt.want)
140+
}
141+
})
142+
}
143+
}

core/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ type Configuration struct {
9090
CredentialsFilePath string `json:"credentialsFilePath,omitempty"`
9191
TokenCustomUrl string `json:"tokenCustomUrl,omitempty"`
9292
Region string `json:"region,omitempty"`
93+
DisableCLIAuthFlow bool `json:"disableCLIAuthFlow,omitempty"` // Should be set to true, if called by STACKIT CLI to avoid infinite loops.
9394
CustomAuth http.RoundTripper
9495
Servers ServerConfigurations
9596
OperationServers map[string]ServerConfigurations

0 commit comments

Comments
 (0)