Skip to content

Commit f5963f5

Browse files
committed
feat(auth): add CLI auth module with token storage and browser login flow
1 parent b92ffc0 commit f5963f5

2 files changed

Lines changed: 255 additions & 0 deletions

File tree

internal/auth/auth.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package auth
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/json"
6+
"fmt"
7+
"math/big"
8+
"os"
9+
"path/filepath"
10+
"time"
11+
)
12+
13+
// StoredAuth represents the persisted authentication state for the CLI.
14+
type StoredAuth struct {
15+
Token string `json:"token"`
16+
Username string `json:"username"`
17+
ExpiresAt time.Time `json:"expires_at"`
18+
CreatedAt time.Time `json:"created_at"`
19+
}
20+
21+
// TokenPath returns the path to the auth token file (~/.openboot/auth.json).
22+
func TokenPath() string {
23+
home, err := os.UserHomeDir()
24+
if err != nil {
25+
return ""
26+
}
27+
return filepath.Join(home, ".openboot", "auth.json")
28+
}
29+
30+
// LoadToken reads and unmarshals the stored auth token from disk.
31+
// Returns nil, nil if the file does not exist or the token is expired.
32+
func LoadToken() (*StoredAuth, error) {
33+
path := TokenPath()
34+
data, err := os.ReadFile(path)
35+
if err != nil {
36+
if os.IsNotExist(err) {
37+
return nil, nil
38+
}
39+
return nil, fmt.Errorf("failed to read auth file: %w", err)
40+
}
41+
42+
var auth StoredAuth
43+
if err := json.Unmarshal(data, &auth); err != nil {
44+
return nil, fmt.Errorf("failed to parse auth file: %w", err)
45+
}
46+
47+
if time.Now().After(auth.ExpiresAt) {
48+
return nil, nil
49+
}
50+
51+
return &auth, nil
52+
}
53+
54+
// SaveToken persists the auth token to disk with restrictive permissions.
55+
func SaveToken(auth *StoredAuth) error {
56+
path := TokenPath()
57+
58+
dir := filepath.Dir(path)
59+
if err := os.MkdirAll(dir, 0700); err != nil {
60+
return fmt.Errorf("failed to create auth directory: %w", err)
61+
}
62+
63+
data, err := json.MarshalIndent(auth, "", " ")
64+
if err != nil {
65+
return fmt.Errorf("failed to marshal auth data: %w", err)
66+
}
67+
68+
if err := os.WriteFile(path, data, 0600); err != nil {
69+
return fmt.Errorf("failed to write auth file: %w", err)
70+
}
71+
72+
return nil
73+
}
74+
75+
// DeleteToken removes the stored auth token file.
76+
func DeleteToken() error {
77+
path := TokenPath()
78+
err := os.Remove(path)
79+
if err != nil && !os.IsNotExist(err) {
80+
return fmt.Errorf("failed to delete auth file: %w", err)
81+
}
82+
return nil
83+
}
84+
85+
// IsAuthenticated returns true if a valid, non-expired token exists on disk.
86+
func IsAuthenticated() bool {
87+
auth, err := LoadToken()
88+
return err == nil && auth != nil
89+
}
90+
91+
// GenerateCode produces an 8-character uppercase alphanumeric code using
92+
// crypto/rand for secure randomness.
93+
func GenerateCode() string {
94+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
95+
code := make([]byte, 8)
96+
for i := range code {
97+
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
98+
if err != nil {
99+
// Extremely unlikely; fallback to a fixed value to avoid panic.
100+
code[i] = 'X'
101+
continue
102+
}
103+
code[i] = charset[n.Int64()]
104+
}
105+
return string(code)
106+
}

internal/auth/login.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package auth
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"os/exec"
10+
"time"
11+
12+
"github.com/openbootdotdev/openboot/internal/ui"
13+
)
14+
15+
const DefaultAPIBase = "https://openboot.dev"
16+
17+
// GetAPIBase returns the API base URL, checking the OPENBOOT_API_URL
18+
// environment variable first and falling back to DefaultAPIBase.
19+
func GetAPIBase() string {
20+
if base := os.Getenv("OPENBOOT_API_URL"); base != "" {
21+
return base
22+
}
23+
return DefaultAPIBase
24+
}
25+
26+
type cliStartRequest struct {
27+
Code string `json:"code"`
28+
}
29+
30+
type cliStartResponse struct {
31+
CodeID string `json:"code_id"`
32+
}
33+
34+
type cliPollResponse struct {
35+
Status string `json:"status"`
36+
Token string `json:"token,omitempty"`
37+
Username string `json:"username,omitempty"`
38+
ExpiresAt string `json:"expires_at,omitempty"`
39+
}
40+
41+
// LoginInteractive performs the full CLI-to-browser authentication flow:
42+
// generates a code, starts the auth session, opens the browser for approval,
43+
// and polls until approved or timed out.
44+
func LoginInteractive(apiBase string) (*StoredAuth, error) {
45+
code := GenerateCode()
46+
47+
codeID, err := startAuthSession(apiBase, code)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
fmt.Fprintf(os.Stderr, "\n")
53+
ui.Info(fmt.Sprintf("Your one-time code is: %s", ui.Green(code)))
54+
fmt.Fprintf(os.Stderr, "\n")
55+
ui.Info("Opening browser to approve...")
56+
57+
approvalURL := fmt.Sprintf("%s/cli-auth?code=%s", apiBase, code)
58+
if err := openBrowser(approvalURL); err != nil {
59+
ui.Warn(fmt.Sprintf("Could not open browser automatically. Please visit:\n %s", approvalURL))
60+
}
61+
62+
fmt.Fprintf(os.Stderr, "\nWaiting for approval...\n")
63+
64+
result, err := pollForApproval(apiBase, codeID)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
expiresAt, err := time.Parse(time.RFC3339, result.ExpiresAt)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to parse expiration time: %w", err)
72+
}
73+
74+
stored := &StoredAuth{
75+
Token: result.Token,
76+
Username: result.Username,
77+
ExpiresAt: expiresAt,
78+
CreatedAt: time.Now(),
79+
}
80+
81+
if err := SaveToken(stored); err != nil {
82+
return nil, fmt.Errorf("failed to save auth token: %w", err)
83+
}
84+
85+
ui.Success(fmt.Sprintf("Authenticated as %s", stored.Username))
86+
return stored, nil
87+
}
88+
89+
func startAuthSession(apiBase, code string) (string, error) {
90+
body, err := json.Marshal(cliStartRequest{Code: code})
91+
if err != nil {
92+
return "", fmt.Errorf("failed to marshal start request: %w", err)
93+
}
94+
95+
resp, err := http.Post(
96+
fmt.Sprintf("%s/api/auth/cli/start", apiBase),
97+
"application/json",
98+
bytes.NewReader(body),
99+
)
100+
if err != nil {
101+
return "", fmt.Errorf("failed to start auth session: %w", err)
102+
}
103+
defer resp.Body.Close()
104+
105+
if resp.StatusCode != http.StatusOK {
106+
return "", fmt.Errorf("auth start failed with status %d", resp.StatusCode)
107+
}
108+
109+
var result cliStartResponse
110+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
111+
return "", fmt.Errorf("failed to parse auth start response: %w", err)
112+
}
113+
114+
return result.CodeID, nil
115+
}
116+
117+
func pollForApproval(apiBase, codeID string) (*cliPollResponse, error) {
118+
pollURL := fmt.Sprintf("%s/api/auth/cli/poll?code_id=%s", apiBase, codeID)
119+
timeout := time.After(5 * time.Minute)
120+
ticker := time.NewTicker(2 * time.Second)
121+
defer ticker.Stop()
122+
123+
for {
124+
select {
125+
case <-timeout:
126+
return nil, fmt.Errorf("authentication timed out after 5 minutes")
127+
case <-ticker.C:
128+
resp, err := http.Get(pollURL)
129+
if err != nil {
130+
continue
131+
}
132+
133+
var result cliPollResponse
134+
decodeErr := json.NewDecoder(resp.Body).Decode(&result)
135+
resp.Body.Close()
136+
if decodeErr != nil {
137+
continue
138+
}
139+
140+
if result.Status == "approved" {
141+
return &result, nil
142+
}
143+
}
144+
}
145+
}
146+
147+
func openBrowser(url string) error {
148+
return exec.Command("open", url).Start()
149+
}

0 commit comments

Comments
 (0)