Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 0 additions & 59 deletions browser_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,65 +14,6 @@ import (
"github.com/go-authgate/cli/tui"
)

// performBrowserFlow runs the Authorization Code Flow with PKCE.
//
// Returns:
// - (storage, true, nil) on success
// - (nil, false, nil) when openBrowser() fails — caller should fall back to Device Code Flow
// - (nil, false, err) on a hard error (CSRF mismatch, token exchange failure, etc.)
func performBrowserFlow(ctx context.Context) (*TokenStorage, bool, error) {
state, err := generateState()
if err != nil {
return nil, false, fmt.Errorf("failed to generate state: %w", err)
}

pkce, err := GeneratePKCE()
if err != nil {
return nil, false, fmt.Errorf("failed to generate PKCE: %w", err)
}

authURL := buildAuthURL(state, pkce)

fmt.Println("Step 1: Opening browser for authorization...")
fmt.Printf("\n %s\n\n", authURL)

if err := openBrowser(ctx, authURL); err != nil {
// Browser failed to open — signal the caller to fall back immediately.
fmt.Printf("Could not open browser: %v\n", err)
return nil, false, nil
}

fmt.Println("Browser opened. Please complete authorization in your browser.")
fmt.Printf("Step 2: Waiting for callback on http://localhost:%d/callback ...\n", callbackPort)

storage, err := startCallbackServer(ctx, callbackPort, state,
func(callbackCtx context.Context, code string) (*TokenStorage, error) {
fmt.Println("Step 3: Exchanging authorization code for tokens...")
return exchangeCode(callbackCtx, code, pkce.Verifier)
})
if err != nil {
if errors.Is(err, ErrCallbackTimeout) {
// User opened the browser but didn't complete authorization in time.
// Fall back to Device Code Flow so they can still authenticate.
fmt.Printf(
"Browser authorization timed out after %s, falling back to Device Code Flow...\n",
callbackTimeout,
)
return nil, false, nil
}
return nil, false, fmt.Errorf("authentication failed: %w", err)
}
storage.Flow = "browser"

if err := saveTokens(storage); err != nil {
fmt.Printf("Warning: Failed to save tokens: %v\n", err)
} else {
fmt.Printf("Tokens saved to %s\n", tokenFile)
}

return storage, true, nil
}

// buildAuthURL constructs the /oauth/authorize URL with all required parameters.
func buildAuthURL(state string, pkce *PKCEParams) string {
params := url.Values{}
Expand Down
50 changes: 0 additions & 50 deletions device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,56 +15,6 @@ import (
"golang.org/x/oauth2"
)

// performDeviceFlow runs the OAuth 2.0 Device Authorization Grant (RFC 8628)
// and returns tokens on success.
func performDeviceFlow(ctx context.Context) (*TokenStorage, error) {
config := &oauth2.Config{
ClientID: clientID,
Endpoint: oauth2.Endpoint{
DeviceAuthURL: serverURL + "/oauth/device/code",
TokenURL: serverURL + "/oauth/token",
},
Scopes: strings.Fields(scope),
}

fmt.Println("Step 1: Requesting device code...")
deviceAuth, err := requestDeviceCode(ctx)
if err != nil {
return nil, fmt.Errorf("device code request failed: %w", err)
}

fmt.Printf("\n----------------------------------------\n")
fmt.Printf("Please open this link to authorize:\n%s\n", deviceAuth.VerificationURIComplete)
fmt.Printf("\nOr visit : %s\n", deviceAuth.VerificationURI)
fmt.Printf("And enter: %s\n", deviceAuth.UserCode)
fmt.Printf("----------------------------------------\n\n")

fmt.Println("Step 2: Waiting for authorization...")
token, err := pollForTokenWithProgress(ctx, config, deviceAuth)
if err != nil {
return nil, fmt.Errorf("token poll failed: %w", err)
}

fmt.Println("\nAuthorization successful!")

storage := &TokenStorage{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.Type(),
ExpiresAt: token.Expiry,
ClientID: clientID,
Flow: "device",
}

if err := saveTokens(storage); err != nil {
fmt.Printf("Warning: Failed to save tokens: %v\n", err)
} else {
fmt.Printf("Tokens saved to %s\n", tokenFile)
}

return storage, nil
}

// requestDeviceCode requests a device code from the OAuth server.
func requestDeviceCode(ctx context.Context) (*oauth2.DeviceAuthResponse, error) {
reqCtx, cancel := context.WithTimeout(ctx, deviceCodeRequestTimeout)
Expand Down
6 changes: 6 additions & 0 deletions tui/browser_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func (m *BrowserModel) Init() tea.Cmd {
func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
//nolint:exhaustive // We only handle Ctrl+C, other keys are ignored
switch msg.Type {
case tea.KeyCtrlC:
// Cancel the OAuth flow
Expand All @@ -85,6 +86,8 @@ func (m *BrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Give a brief moment for the cancellation to propagate
time.Sleep(100 * time.Millisecond)
return m, tea.Quit
default:
// Ignore other keys
}

case tea.WindowSizeMsg:
Expand Down Expand Up @@ -174,6 +177,9 @@ func (m *BrowserModel) handleFlowUpdate(update FlowUpdate) tea.Cmd {

case CallbackReceived:
m.status = "Callback received, exchanging tokens..."

case DeviceCodeReceived, PollingUpdate, BackoffChanged:
// These are device flow specific, ignore in browser flow
}

return waitForUpdate(m.updatesCh)
Expand Down
7 changes: 4 additions & 3 deletions tui/browser_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,20 @@ func renderBrowserView(m *BrowserModel) string {
func renderBrowserComplete(m *BrowserModel) string {
var b strings.Builder

if m.err != nil {
switch {
case m.err != nil:
// Error state
b.WriteString("\n")
b.WriteString(RenderError(fmt.Sprintf("Authentication failed: %v", m.err)))
b.WriteString("\n\n")
} else if !m.ok {
case !m.ok:
// Fallback to device flow
b.WriteString("\n")
b.WriteString(
RenderWarning("Browser flow unavailable, falling back to Device Code Flow..."),
)
b.WriteString("\n\n")
} else {
default:
// Success state
b.WriteString("\n")
successBox := lipgloss.NewStyle().
Expand Down
14 changes: 8 additions & 6 deletions tui/components/step_indicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,16 @@ func (s *StepIndicator) View() string {
label = fmt.Sprintf("Step %d", i)
}

if i < s.CurrentStep {
switch {
case i < s.CurrentStep:
// Completed step
symbol = "●"
style = completedStyle
} else if i == s.CurrentStep {
case i == s.CurrentStep:
// Current step
symbol = "●"
style = currentStyle
} else {
default:
// Pending step
symbol = "○"
style = pendingStyle
Expand All @@ -89,11 +90,12 @@ func (s *StepIndicator) ViewCompact() string {

var symbols []string
for i := 1; i <= s.TotalSteps; i++ {
if i < s.CurrentStep {
switch {
case i < s.CurrentStep:
symbols = append(symbols, completedStyle.Render("●"))
} else if i == s.CurrentStep {
case i == s.CurrentStep:
symbols = append(symbols, currentStyle.Render("●"))
} else {
default:
symbols = append(symbols, pendingStyle.Render("○"))
}
}
Expand Down
6 changes: 6 additions & 0 deletions tui/device_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func (m *DeviceModel) Init() tea.Cmd {
func (m *DeviceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
//nolint:exhaustive // We only handle Ctrl+C, other keys are ignored
switch msg.Type {
case tea.KeyCtrlC:
// Cancel the OAuth flow
Expand All @@ -88,6 +89,8 @@ func (m *DeviceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Give a brief moment for the cancellation to propagate
time.Sleep(100 * time.Millisecond)
return m, tea.Quit
default:
// Ignore other keys
}

case tea.WindowSizeMsg:
Expand Down Expand Up @@ -180,6 +183,9 @@ func (m *DeviceModel) handleFlowUpdate(update FlowUpdate) tea.Cmd {
case TimerTick:
m.elapsed = update.GetDuration("elapsed")
m.timer.Update(m.elapsed)

case BrowserOpened, CallbackReceived:
// These are browser flow specific, ignore in device flow
}

return waitForUpdate(m.updatesCh)
Expand Down
2 changes: 1 addition & 1 deletion tui/device_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func formatInterval(d time.Duration) string {
return fmt.Sprintf("%ds", seconds)
}
minutes := seconds / 60
seconds = seconds % 60
seconds %= 60
if seconds == 0 {
return fmt.Sprintf("%dm", minutes)
}
Expand Down
28 changes: 19 additions & 9 deletions tui/simple_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,20 @@ func (m *SimpleManager) RunBrowserFlow(
func (m *SimpleManager) handleBrowserUpdate(update FlowUpdate) {
switch update.Type {
case StepStart:
if update.Step == 1 {
switch update.Step {
case 1:
fmt.Println("Step 1: Opening browser for authorization...")
if url := update.GetString("url"); url != "" {
fmt.Printf("\n %s\n\n", url)
}
} else if update.Step == 2 {
case 2:
fmt.Println("Browser opened. Please complete authorization in your browser.")
port := update.GetInt("port")
if port == 0 {
port = 8888 // default
}
fmt.Printf("Step 2: Waiting for callback on http://localhost:%d/callback ...\n", port)
} else if update.Step == 3 {
case 3:
fmt.Println("Step 3: Exchanging authorization code for tokens...")
}

Expand All @@ -101,6 +102,9 @@ func (m *SimpleManager) handleBrowserUpdate(update FlowUpdate) {
case CallbackReceived:
// Token exchange message printed in StepStart for step 3
return

case StepProgress, StepComplete, TimerTick, DeviceCodeReceived, PollingUpdate, BackoffChanged:
// Not displayed in simple mode
}
}

Expand Down Expand Up @@ -146,14 +150,18 @@ func (m *SimpleManager) RunDeviceFlow(
}

func (m *SimpleManager) handleDeviceUpdate(update FlowUpdate) {
switch update.Type {
case StepStart:
if update.Step == 1 {
switch update.Step {
case 1:
if update.Type == StepStart {
fmt.Println("Step 1: Requesting device code...")
} else if update.Step == 2 {
}
case 2:
if update.Type == StepStart {
fmt.Println("Step 2: Waiting for authorization...")
}
}

switch update.Type {
case DeviceCodeReceived:
userCode := update.GetString("user_code")
verificationURI := update.GetString("verification_uri")
Expand Down Expand Up @@ -183,6 +191,9 @@ func (m *SimpleManager) handleDeviceUpdate(update FlowUpdate) {
fmt.Println()
fmt.Println(update.Message)
}

case StepStart, StepProgress, TimerTick, BrowserOpened, CallbackReceived:
// Already handled or not displayed in simple mode
}
}

Expand All @@ -197,8 +208,7 @@ func (m *SimpleManager) ShowTokenInfo(storage *TokenStorage) {
fmt.Printf("Token Type : %s\n", storage.TokenType)

// Handle ExpiresAt being either time.Time or interface{}
switch expiresAt := storage.ExpiresAt.(type) {
case time.Time:
if expiresAt, ok := storage.ExpiresAt.(time.Time); ok {
fmt.Printf("Expires In : %s\n", time.Until(expiresAt).Round(time.Second))
}

Expand Down
Loading