Skip to content

Commit 1b1a5a4

Browse files
authored
Merge pull request #65 from thalassa-cloud/browser-login
feat(login): support browser based login for tcloud context
2 parents b61b328 + 255d9a9 commit 1b1a5a4

23 files changed

Lines changed: 1154 additions & 23 deletions

‎.githooks/pre-commit‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if ! command -v golangci-lint >/dev/null 2>&1; then
5+
echo "pre-commit: golangci-lint not found; install it from https://golangci-lint.run/welcome/install/" >&2
6+
exit 1
7+
fi
8+
9+
mapfile -t staged_go_files < <(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' || true)
10+
if ((${#staged_go_files[@]} == 0)); then
11+
exit 0
12+
fi
13+
14+
echo "pre-commit: gofmt"
15+
gofmt -w "${staged_go_files[@]}"
16+
git add -- "${staged_go_files[@]}"
17+
18+
echo "pre-commit: golangci-lint run"
19+
golangci-lint run ./...

‎Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ test-e2e: build ## Run E2E tests (requires TCLOUD_E2E_* environment variables)
4545
docs:
4646
go run tools/docs.go
4747

48+
install-hooks: ## Install git pre-commit hooks (go fmt, golangci-lint)
49+
@./scripts/install-githooks.sh
50+
4851
clean:
4952
-rm -f bin/${BINARY}-* bin/${BINARY}
5053

51-
.PHONY: link linux darwin windows test test-e2e fmt clean docs
54+
.PHONY: link linux darwin windows test test-e2e fmt clean docs install-hooks

‎cmd/context/create.go‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ var createCmd = &cobra.Command{
4242
oidcClientID := contextstate.ClientIdOrFlag()
4343
oidcClientSecret := contextstate.ClientSecretOrFlag()
4444
accessToken := contextstate.AccessToken()
45-
if token == "" && oidcClientID == "" && oidcClientSecret == "" && accessToken == "" {
46-
return errors.New("no token or oidc client id and secret set")
47-
}
45+
useBrowserLogin := contextstate.BrowserLoginFlag || (token == "" && oidcClientID == "" && oidcClientSecret == "" && accessToken == "")
4846

49-
if accessToken != "" {
47+
if useBrowserLogin {
48+
if err := contextstate.LoginWithBrowserOIDC(ctx, apiURL, contextName); err != nil {
49+
return fmt.Errorf("failed to login with browser: %w", err)
50+
}
51+
} else if accessToken != "" {
5052
if strings.HasPrefix(accessToken, "tc_pat_") {
5153
return errors.New("access token is a personal access token, use 'tcloud context login --token <token>' to login with a personal access token")
5254
}
@@ -128,6 +130,7 @@ func init() {
128130

129131
createCmd.Flags().BoolVar(&createContext, "create-context", true, "creates a context")
130132
createCmd.Flags().StringVar(&contextName, "name", "default", "name of the context")
133+
createCmd.Flags().BoolVar(&contextstate.BrowserLoginFlag, "browser", false, "log in through the browser using OIDC (default when no other credentials are provided)")
131134
}
132135

133136
func newDefaultContext(contextName, organisation, project, apiName string) contextstate.Context {

‎cmd/context/login.go‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,26 @@ import (
99
"github.com/thalassa-cloud/cli/internal/config/contextstate"
1010
)
1111

12-
// createCmd represents the create command
1312
var loginCmd = &cobra.Command{
1413
Use: "login",
1514
Short: "Login to Thalassa Cloud",
16-
Long: "Login to Thalassa Cloud using a personal access token, access token, or OIDC client id and secret, using the current context. Overrides the current context if --name is set.",
15+
Long: "Login to Thalassa Cloud using browser OIDC (default when no credentials are provided), a personal access token, access token, or OIDC client credentials for the current context.",
1716
Args: cobra.NoArgs,
1817
RunE: func(cmd *cobra.Command, args []string) error {
19-
token := contextstate.PersonalAccessToken()
20-
accessToken := contextstate.AccessToken()
2118
apiURL := contextstate.Server()
22-
2319
if apiURL == "" {
2420
return errors.New("api endpoint is required")
2521
}
2622

23+
token := contextstate.PersonalAccessToken()
24+
accessToken := contextstate.AccessToken()
2725
oidcClientID := contextstate.ClientIdOrFlag()
2826
oidcClientSecret := contextstate.ClientSecretOrFlag()
2927

28+
if contextstate.BrowserLoginFlag || (token == "" && oidcClientID == "" && oidcClientSecret == "" && accessToken == "") {
29+
return contextstate.LoginWithBrowserOIDC(cmd.Context(), apiURL, contextName)
30+
}
31+
3032
if oidcClientID != "" && oidcClientSecret != "" {
3133
return contextstate.LoginWithAPIEndpointOidc(cmd.Context(), oidcClientID, oidcClientSecret, apiURL)
3234
}
@@ -36,9 +38,6 @@ var loginCmd = &cobra.Command{
3638
}
3739
return contextstate.LoginWithAccessToken(cmd.Context(), accessToken, apiURL)
3840
}
39-
if token == "" {
40-
return errors.New("no authentication method provided")
41-
}
4241
return contextstate.LoginWithAPIEndpoint(cmd.Context(), token, apiURL)
4342
},
4443
}
@@ -47,4 +46,5 @@ func init() {
4746
ContextCmd.AddCommand(loginCmd)
4847

4948
loginCmd.Flags().StringVar(&contextName, "name", "default", "name of the context")
49+
loginCmd.Flags().BoolVar(&contextstate.BrowserLoginFlag, "browser", false, "log in through the browser using OIDC (default when no other credentials are provided)")
5050
}

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/stretchr/testify v1.11.1
1313
github.com/thalassa-cloud/client-go v0.34.0
1414
github.com/zalando/go-keyring v0.2.8
15+
golang.org/x/oauth2 v0.36.0
1516
gopkg.in/yaml.v3 v3.0.1
1617
k8s.io/apimachinery v0.36.2
1718
sigs.k8s.io/yaml v1.6.0
@@ -37,7 +38,6 @@ require (
3738
go.yaml.in/yaml/v2 v2.4.3 // indirect
3839
go.yaml.in/yaml/v3 v3.0.4 // indirect
3940
golang.org/x/net v0.49.0 // indirect
40-
golang.org/x/oauth2 v0.36.0 // indirect
4141
golang.org/x/sys v0.40.0 // indirect
4242
golang.org/x/time v0.15.0 // indirect
4343
gopkg.in/inf.v0 v0.9.1 // indirect
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package browseroidc
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/base64"
7+
"errors"
8+
"fmt"
9+
"net"
10+
"net/http"
11+
"net/url"
12+
"os/exec"
13+
"runtime"
14+
"strings"
15+
"time"
16+
17+
"golang.org/x/oauth2"
18+
)
19+
20+
var (
21+
ErrLoginTimeout = errors.New("browser login timed out")
22+
ErrStateMismatch = errors.New("login state mismatch")
23+
ErrMissingCode = errors.New("authorization code missing")
24+
ErrMissingTokens = errors.New("access token missing from token response")
25+
)
26+
27+
// TokenPair holds OAuth tokens returned by browser OIDC login.
28+
type TokenPair struct {
29+
AccessToken string
30+
RefreshToken string
31+
Expiry time.Time
32+
}
33+
34+
// BrowserLoginOptions configures an interactive browser login.
35+
type BrowserLoginOptions struct {
36+
RedirectURL string
37+
OpenURL func(string) error
38+
Timeout time.Duration
39+
}
40+
41+
// BrowserLogin runs the authorization code flow with PKCE for the tcloud public client.
42+
func BrowserLogin(ctx context.Context, opts BrowserLoginOptions) (TokenPair, error) {
43+
if opts.OpenURL == nil {
44+
opts.OpenURL = openBrowser
45+
}
46+
if opts.Timeout == 0 {
47+
opts.Timeout = 5 * time.Minute
48+
}
49+
50+
redirectURLString := opts.RedirectURL
51+
var listener net.Listener
52+
var err error
53+
if redirectURLString == "" {
54+
listener, redirectURLString, err = listenOnCallbackPorts(CallbackPorts)
55+
} else {
56+
if err := validateLoopbackRedirectURL(redirectURLString); err != nil {
57+
return TokenPair{}, err
58+
}
59+
redirectURL, parseErr := url.Parse(redirectURLString)
60+
if parseErr != nil {
61+
return TokenPair{}, fmt.Errorf("parse redirect url: %w", parseErr)
62+
}
63+
listener, err = net.Listen("tcp", net.JoinHostPort(defaultCallbackHost, redirectURL.Port()))
64+
}
65+
if err != nil {
66+
return TokenPair{}, fmt.Errorf("start callback listener: %w", err)
67+
}
68+
defer func() { _ = listener.Close() }()
69+
70+
redirectURL, err := url.Parse(redirectURLString)
71+
if err != nil {
72+
return TokenPair{}, fmt.Errorf("parse redirect url: %w", err)
73+
}
74+
75+
state, err := randomString(32)
76+
if err != nil {
77+
return TokenPair{}, err
78+
}
79+
verifier := oauth2.GenerateVerifier()
80+
81+
authEndpoint, err := authURL()
82+
if err != nil {
83+
return TokenPair{}, err
84+
}
85+
tokenEndpoint, err := tokenURL()
86+
if err != nil {
87+
return TokenPair{}, err
88+
}
89+
90+
conf := oauth2.Config{
91+
ClientID: ClientID,
92+
Endpoint: oauth2.Endpoint{AuthURL: authEndpoint, TokenURL: tokenEndpoint},
93+
RedirectURL: redirectURLString,
94+
Scopes: []string{"openid", "offline_access", "profile", "email"},
95+
}
96+
97+
codeCh := make(chan string, 1)
98+
errCh := make(chan error, 1)
99+
100+
mux := http.NewServeMux()
101+
server := &http.Server{
102+
Handler: mux,
103+
ReadHeaderTimeout: 5 * time.Second,
104+
ReadTimeout: 10 * time.Second,
105+
WriteTimeout: 10 * time.Second,
106+
}
107+
108+
mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
109+
if r.Method != http.MethodGet {
110+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
111+
return
112+
}
113+
if queryState := r.URL.Query().Get("state"); queryState != state {
114+
errCh <- ErrStateMismatch
115+
http.Error(w, "invalid login state", http.StatusBadRequest)
116+
return
117+
}
118+
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
119+
desc := r.URL.Query().Get("error_description")
120+
errCh <- fmt.Errorf("authorization failed: %s: %s", errMsg, desc)
121+
http.Error(w, "login failed", http.StatusBadRequest)
122+
return
123+
}
124+
code := r.URL.Query().Get("code")
125+
if code == "" {
126+
errCh <- ErrMissingCode
127+
http.Error(w, "authorization code missing", http.StatusBadRequest)
128+
return
129+
}
130+
writeLoginSuccessPage(w)
131+
codeCh <- code
132+
})
133+
134+
go func() {
135+
if serveErr := server.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
136+
errCh <- serveErr
137+
}
138+
}()
139+
defer func() { _ = server.Shutdown(context.Background()) }()
140+
141+
authRequest := conf.AuthCodeURL(
142+
state,
143+
oauth2.AccessTypeOffline,
144+
oauth2.S256ChallengeOption(verifier),
145+
)
146+
if err := opts.OpenURL(authRequest); err != nil {
147+
return TokenPair{}, fmt.Errorf("open browser: %w", err)
148+
}
149+
150+
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
151+
defer cancel()
152+
153+
var code string
154+
select {
155+
case <-ctx.Done():
156+
return TokenPair{}, ErrLoginTimeout
157+
case err := <-errCh:
158+
return TokenPair{}, err
159+
case code = <-codeCh:
160+
}
161+
162+
token, err := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
163+
if err != nil {
164+
return TokenPair{}, fmt.Errorf("exchange authorization code: %w", err)
165+
}
166+
if token.AccessToken == "" {
167+
return TokenPair{}, ErrMissingTokens
168+
}
169+
170+
return TokenPair{
171+
AccessToken: token.AccessToken,
172+
RefreshToken: token.RefreshToken,
173+
Expiry: token.Expiry,
174+
}, nil
175+
}
176+
177+
// RefreshAccessToken uses a refresh token to obtain a new access token.
178+
func RefreshAccessToken(ctx context.Context, refreshToken string) (TokenPair, error) {
179+
refreshToken = strings.TrimSpace(refreshToken)
180+
if refreshToken == "" {
181+
return TokenPair{}, errors.New("refresh token is required")
182+
}
183+
184+
tokenEndpoint, err := tokenURL()
185+
if err != nil {
186+
return TokenPair{}, err
187+
}
188+
189+
conf := oauth2.Config{
190+
ClientID: ClientID,
191+
Endpoint: oauth2.Endpoint{TokenURL: tokenEndpoint},
192+
}
193+
tokenSource := conf.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken})
194+
token, err := tokenSource.Token()
195+
if err != nil {
196+
return TokenPair{}, fmt.Errorf("refresh access token: %w", err)
197+
}
198+
if token.AccessToken == "" {
199+
return TokenPair{}, ErrMissingTokens
200+
}
201+
202+
refresh := token.RefreshToken
203+
if refresh == "" {
204+
refresh = refreshToken
205+
}
206+
207+
return TokenPair{
208+
AccessToken: token.AccessToken,
209+
RefreshToken: refresh,
210+
Expiry: token.Expiry,
211+
}, nil
212+
}
213+
214+
func openBrowser(url string) error {
215+
switch runtime.GOOS {
216+
case "darwin":
217+
return exec.Command("open", url).Start()
218+
case "windows":
219+
return exec.Command("cmd", "/c", "start", url).Start()
220+
default:
221+
return exec.Command("xdg-open", url).Start()
222+
}
223+
}
224+
225+
func randomString(size int) (string, error) {
226+
buf := make([]byte, size)
227+
if _, err := rand.Read(buf); err != nil {
228+
return "", err
229+
}
230+
return base64.RawURLEncoding.EncodeToString(buf), nil
231+
}
232+
233+
func listenOnCallbackPorts(ports []string) (net.Listener, string, error) {
234+
var lastErr error
235+
for _, port := range ports {
236+
listener, err := net.Listen("tcp", net.JoinHostPort(defaultCallbackHost, port))
237+
if err == nil {
238+
return listener, RedirectURLForPort(port), nil
239+
}
240+
lastErr = err
241+
}
242+
if lastErr == nil {
243+
lastErr = errors.New("no callback ports configured")
244+
}
245+
return nil, "", lastErr
246+
}

0 commit comments

Comments
 (0)