Skip to content

Commit 38a2498

Browse files
Add device flow authentication (#18)
1 parent aed0266 commit 38a2498

7 files changed

Lines changed: 513 additions & 64 deletions

File tree

internal/api/client.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"log"
9+
"net/http"
10+
"os"
11+
"time"
12+
)
13+
14+
type PlatformAPI interface {
15+
CreateAuthRequest(ctx context.Context) (*AuthRequest, error)
16+
CheckAuthRequestConfirmed(ctx context.Context, id, exchangeToken string) (bool, error)
17+
ExchangeAuthRequest(ctx context.Context, id, exchangeToken string) (string, error)
18+
GetLicenseToken(ctx context.Context, bearerToken string) (string, error)
19+
}
20+
21+
type AuthRequest struct {
22+
ID string `json:"id"`
23+
Code string `json:"code"`
24+
ExchangeToken string `json:"exchange_token"`
25+
}
26+
27+
type authRequestStatus struct {
28+
Confirmed bool `json:"confirmed"`
29+
}
30+
31+
type authTokenResponse struct {
32+
ID string `json:"id"`
33+
AuthToken string `json:"auth_token"`
34+
}
35+
36+
type licenseTokenResponse struct {
37+
Token string `json:"token"`
38+
}
39+
40+
type PlatformClient struct {
41+
baseURL string
42+
httpClient *http.Client
43+
}
44+
45+
func NewPlatformClient() *PlatformClient {
46+
baseURL := os.Getenv("LOCALSTACK_API_ENDPOINT")
47+
if baseURL == "" {
48+
baseURL = "https://api.localstack.cloud"
49+
}
50+
return &PlatformClient{
51+
baseURL: baseURL,
52+
httpClient: &http.Client{Timeout: 30 * time.Second},
53+
}
54+
}
55+
56+
func (c *PlatformClient) CreateAuthRequest(ctx context.Context) (*AuthRequest, error) {
57+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/auth/request", nil)
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to create request: %w", err)
60+
}
61+
62+
resp, err := c.httpClient.Do(req)
63+
if err != nil {
64+
return nil, fmt.Errorf("failed to create auth request: %w", err)
65+
}
66+
defer func() {
67+
if err := resp.Body.Close(); err != nil {
68+
log.Printf("failed to close response body: %v", err)
69+
}
70+
}()
71+
72+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
73+
return nil, fmt.Errorf("failed to create auth request: status %d", resp.StatusCode)
74+
}
75+
76+
var authReq AuthRequest
77+
if err := json.NewDecoder(resp.Body).Decode(&authReq); err != nil {
78+
return nil, fmt.Errorf("failed to decode response: %w", err)
79+
}
80+
81+
return &authReq, nil
82+
}
83+
84+
func (c *PlatformClient) CheckAuthRequestConfirmed(ctx context.Context, id, exchangeToken string) (bool, error) {
85+
url := fmt.Sprintf("%s/v1/auth/request/%s?exchange_token=%s", c.baseURL, id, exchangeToken)
86+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
87+
if err != nil {
88+
return false, fmt.Errorf("failed to create request: %w", err)
89+
}
90+
91+
resp, err := c.httpClient.Do(req)
92+
if err != nil {
93+
return false, fmt.Errorf("failed to check auth request: %w", err)
94+
}
95+
defer func() {
96+
if err := resp.Body.Close(); err != nil {
97+
log.Printf("failed to close response body: %v", err)
98+
}
99+
}()
100+
101+
if resp.StatusCode != http.StatusOK {
102+
return false, fmt.Errorf("failed to check auth request: status %d", resp.StatusCode)
103+
}
104+
105+
var status authRequestStatus
106+
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
107+
return false, fmt.Errorf("failed to decode response: %w", err)
108+
}
109+
110+
return status.Confirmed, nil
111+
}
112+
113+
func (c *PlatformClient) ExchangeAuthRequest(ctx context.Context, id, exchangeToken string) (string, error) {
114+
url := fmt.Sprintf("%s/v1/auth/request/%s/exchange", c.baseURL, id)
115+
body, err := json.Marshal(map[string]string{"exchange_token": exchangeToken})
116+
if err != nil {
117+
return "", fmt.Errorf("failed to marshal request: %w", err)
118+
}
119+
120+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
121+
if err != nil {
122+
return "", fmt.Errorf("failed to create request: %w", err)
123+
}
124+
req.Header.Set("Content-Type", "application/json")
125+
126+
resp, err := c.httpClient.Do(req)
127+
if err != nil {
128+
return "", fmt.Errorf("failed to exchange auth request: %w", err)
129+
}
130+
defer func() {
131+
if err := resp.Body.Close(); err != nil {
132+
log.Printf("failed to close response body: %v", err)
133+
}
134+
}()
135+
136+
if resp.StatusCode != http.StatusOK {
137+
return "", fmt.Errorf("failed to exchange auth request: status %d", resp.StatusCode)
138+
}
139+
140+
var tokenResp authTokenResponse
141+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
142+
return "", fmt.Errorf("failed to decode response: %w", err)
143+
}
144+
145+
return tokenResp.AuthToken, nil
146+
}
147+
148+
func (c *PlatformClient) GetLicenseToken(ctx context.Context, bearerToken string) (string, error) {
149+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/license/credentials", nil)
150+
if err != nil {
151+
return "", fmt.Errorf("failed to create request: %w", err)
152+
}
153+
req.Header.Set("Authorization", bearerToken)
154+
155+
resp, err := c.httpClient.Do(req)
156+
if err != nil {
157+
return "", fmt.Errorf("failed to get license token: %w", err)
158+
}
159+
defer func() {
160+
if err := resp.Body.Close(); err != nil {
161+
log.Printf("failed to close response body: %v", err)
162+
}
163+
}()
164+
165+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
166+
return "", fmt.Errorf("failed to get license token: status %d", resp.StatusCode)
167+
}
168+
169+
var tokenResp licenseTokenResponse
170+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
171+
return "", fmt.Errorf("failed to decode response: %w", err)
172+
}
173+
174+
return tokenResp.Token, nil
175+
}

internal/auth/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type Auth struct {
1717
func New() *Auth {
1818
return &Auth{
1919
keyring: systemKeyring{},
20-
browserLogin: browserLogin{},
20+
browserLogin: newBrowserLogin(),
2121
}
2222
}
2323

internal/auth/login.go

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,40 @@ package auth
33
//go:generate mockgen -source=login.go -destination=mock_login_test.go -package=auth
44

55
import (
6+
"bufio"
67
"context"
78
"fmt"
89
"log"
910
"net"
1011
"net/http"
1112
"os"
1213

14+
"github.com/localstack/lstk/internal/api"
1315
"github.com/pkg/browser"
1416
)
1517

18+
const webAppURL = "https://app.localstack.cloud"
19+
const loginCallbackURL = "127.0.0.1:45678"
20+
1621
type LoginProvider interface {
1722
Login(ctx context.Context) (string, error)
1823
}
1924

20-
type browserLogin struct{}
25+
type browserLogin struct {
26+
platformClient api.PlatformAPI
27+
}
28+
29+
func newBrowserLogin() *browserLogin {
30+
return &browserLogin{
31+
platformClient: api.NewPlatformClient(),
32+
}
33+
}
2134

22-
func (browserLogin) Login(ctx context.Context) (string, error) {
23-
listener, err := net.Listen("tcp", "127.0.0.1:45678")
35+
func startCallbackServer() (*http.Server, chan string, chan error, error) {
36+
listener, err := net.Listen("tcp", loginCallbackURL)
2437
if err != nil {
25-
return "", fmt.Errorf("failed to start callback server: %w", err)
38+
return nil, nil, nil, fmt.Errorf("failed to start callback server: %w", err)
2639
}
27-
defer func() {
28-
if err := listener.Close(); err != nil {
29-
log.Printf("failed to close listener: %v", err)
30-
}
31-
}()
3240

3341
tokenCh := make(chan string, 1)
3442
errCh := make(chan error, 1)
@@ -44,28 +52,67 @@ func (browserLogin) Login(ctx context.Context) (string, error) {
4452
w.WriteHeader(http.StatusOK)
4553
tokenCh <- token
4654
})
55+
4756
server := &http.Server{Handler: mux}
4857
go func() {
4958
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
5059
errCh <- fmt.Errorf("callback server error: %w", err)
5160
}
5261
}()
62+
63+
return server, tokenCh, errCh, nil
64+
}
65+
66+
func (b *browserLogin) Login(ctx context.Context) (string, error) {
67+
server, tokenCh, errCh, err := startCallbackServer()
68+
if err != nil {
69+
return "", err
70+
}
5371
defer func() {
5472
if err := server.Shutdown(ctx); err != nil {
5573
log.Printf("failed to shutdown server: %v", err)
5674
}
5775
}()
5876

77+
enterCh := make(chan struct{}, 1)
78+
79+
// Device flow as fallback
80+
authReq, err := b.platformClient.CreateAuthRequest(ctx)
81+
if err != nil {
82+
return "", fmt.Errorf("failed to create auth request: %w", err)
83+
}
84+
85+
deviceURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID)
86+
87+
// Try to open browser
5988
loginURL := fmt.Sprintf("%s/redirect?name=CLI", getWebAppURL())
60-
if err := browser.OpenURL(loginURL); err != nil {
61-
log.Printf("Could not open browser. Please visit: %s", loginURL)
89+
browserOpened := browser.OpenURL(loginURL) == nil
90+
91+
// Display device flow instructions
92+
if browserOpened {
93+
fmt.Printf("Browser didn't open? Open %s to authorize device.\n", deviceURL)
94+
} else {
95+
fmt.Printf("Open %s to authorize device.\n", deviceURL)
6296
}
97+
fmt.Printf("Verification code: %s\n", authReq.Code)
98+
fmt.Println("Waiting for authentication... (Press ENTER when complete)")
6399

100+
// Listen for ENTER key in background
101+
go func() {
102+
reader := bufio.NewReader(os.Stdin)
103+
_, _ = reader.ReadString('\n')
104+
enterCh <- struct{}{}
105+
}()
106+
107+
// Wait for either browser callback, ENTER key, or context cancellation
64108
select {
65109
case token := <-tokenCh:
66110
return token, nil
67111
case err := <-errCh:
68112
return "", err
113+
case <-enterCh:
114+
// User pressed ENTER, try device flow
115+
return b.completeDeviceFlow(ctx, authReq)
69116
case <-ctx.Done():
70117
return "", ctx.Err()
71118
}
@@ -76,5 +123,30 @@ func getWebAppURL() string {
76123
if url := os.Getenv("LOCALSTACK_WEB_APP_URL"); url != "" {
77124
return url
78125
}
79-
return "https://app.localstack.cloud"
126+
return webAppURL
127+
}
128+
129+
func (b *browserLogin) completeDeviceFlow(ctx context.Context, authReq *api.AuthRequest) (string, error) {
130+
log.Println("Checking if auth request is confirmed...")
131+
confirmed, err := b.platformClient.CheckAuthRequestConfirmed(ctx, authReq.ID, authReq.ExchangeToken)
132+
if err != nil {
133+
return "", fmt.Errorf("failed to check auth request: %w", err)
134+
}
135+
if !confirmed {
136+
return "", fmt.Errorf("auth request not confirmed - please enter the code in the browser first")
137+
}
138+
log.Println("Auth request confirmed, exchanging for token...")
139+
140+
bearerToken, err := b.platformClient.ExchangeAuthRequest(ctx, authReq.ID, authReq.ExchangeToken)
141+
if err != nil {
142+
return "", fmt.Errorf("failed to exchange auth request: %w", err)
143+
}
144+
145+
log.Println("Fetching license token...")
146+
licenseToken, err := b.platformClient.GetLicenseToken(ctx, bearerToken)
147+
if err != nil {
148+
return "", fmt.Errorf("failed to get license token: %w", err)
149+
}
150+
151+
return licenseToken, nil
80152
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package integration_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"os/exec"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"github.com/zalando/go-keyring"
13+
)
14+
15+
func TestBrowserFlowStoresToken(t *testing.T) {
16+
requireDocker(t)
17+
cleanup()
18+
t.Cleanup(cleanup)
19+
20+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
21+
defer cancel()
22+
23+
cmd := exec.CommandContext(ctx, binaryPath(), "start")
24+
cmd.Env = envWithoutAuthToken()
25+
26+
// Keep stdin open so ENTER listener doesn't trigger immediately
27+
stdinPipe, err := cmd.StdinPipe()
28+
require.NoError(t, err)
29+
defer stdinPipe.Close()
30+
31+
// Capture output asynchronously
32+
output := make(chan []byte)
33+
go func() {
34+
out, _ := cmd.CombinedOutput()
35+
output <- out
36+
}()
37+
38+
// Wait for callback server to be ready
39+
time.Sleep(1 * time.Second)
40+
41+
// Simulate browser callback with mock token
42+
resp, err := http.Get("http://127.0.0.1:45678/auth/success?token=mock-token")
43+
require.NoError(t, err)
44+
require.NoError(t, resp.Body.Close())
45+
46+
out := <-output
47+
48+
// Login should succeed, but container will fail with invalid token
49+
assert.Contains(t, string(out), "Login successful")
50+
assert.Contains(t, string(out), "License activation failed")
51+
52+
// Verify token was stored in keyring
53+
storedToken, err := keyring.Get(keyringService, keyringUser)
54+
require.NoError(t, err, "token should be stored in keyring")
55+
assert.Equal(t, "mock-token", storedToken)
56+
}

0 commit comments

Comments
 (0)