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
111 changes: 111 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,117 @@ On each run (`main.go:run()`):
- OAuth errors parsed from JSON response: `error` and `error_description` fields
- HTTP retry client (`github.com/appleboy/go-httpretry`) wraps all OAuth requests

### Terminal UI Architecture

**TUI Package (`tui/`)**

The CLI features an interactive Terminal User Interface (TUI) built with Bubble Tea, providing visual feedback during OAuth flows.

**Manager Interface Pattern:**

```go
type Manager interface {
ShowHeader(clientMode, serverURL, clientID string)
ShowFlowSelection(method string)
RunBrowserFlow(ctx context.Context, perform BrowserFlowFunc) (*TokenStorage, bool, error)
RunDeviceFlow(ctx context.Context, perform DeviceFlowFunc) (*TokenStorage, error)
ShowTokenInfo(storage *TokenStorage)
// ... other display methods
}
```

**Two Implementations:**

1. **SimplePrintManager** (`tui/simple_manager.go`):
- Uses `fmt.Printf` for simple, parseable output
- Preserves original CLI behavior (backward compatible)
- Used in CI environments, piped output, small terminals
- No dependencies beyond standard library

2. **BubbleTeaManager** (`tui/bubbletea_manager.go`):
- Rich interactive TUI with Bubble Tea
- Animated spinners, progress bars, timers
- Visual step indicators and status updates
- Gracefully falls back to SimplePrintManager on error

**Automatic UI Selection:**

The CLI auto-detects the environment (`tui/manager.go:shouldUseSimpleUI()`):

- **Simple Mode**: CI environments (GITHUB_ACTIONS, GITLAB_CI, etc.), non-TTY output, TERM=dumb, small terminals (<60x20)
- **Interactive Mode**: Normal terminals with sufficient size and TTY support
- **Fully Automatic**: No flags needed, environment detection handles everything

**Message Passing Architecture:**

OAuth flow functions send progress updates through channels:

```go
type FlowUpdate struct {
Type FlowUpdateType // StepStart, StepProgress, StepComplete, etc.
Step int // Current step (1-indexed)
TotalSteps int // Total steps in flow
Message string // Human-readable status
Progress float64 // 0.0 to 1.0 for progress bars
Data map[string]any // Additional contextual data
}
```

**Flow Wrapper Pattern:**

Original OAuth functions remain unchanged. Wrapper functions add progress reporting:

- `performBrowserFlowWithUpdates()` wraps `performBrowserFlow()`
- `performDeviceFlowWithUpdates()` wraps `performDeviceFlow()`
- Wrappers send FlowUpdate messages through channels
- TUI models subscribe to these channels and update UI

**Bubble Tea Models:**

- **BrowserModel** (`tui/browser_model.go`, `tui/browser_view.go`):
- Displays step progress, countdown timer, progress bar
- Shows authorization URL in step 1
- Animated spinner during callback wait
- Handles timeout and error states

- **DeviceModel** (`tui/device_model.go`, `tui/device_view.go`):
- Shows device code in bordered box
- Displays verification URL and user code
- Live polling status with count and interval
- Backoff warnings when server requests slower polling
- Elapsed time counter

**Reusable Components (`tui/components/`):**

- `StepIndicator`: Multi-step progress (●/○ symbols)
- `Timer`: Countdown or elapsed time display
- `ProgressBar`: Visual progress bar with percentage
- `InfoBox`: Bordered information display

**Styling System (`tui/styles.go`):**

- Consistent color palette (primary, secondary, success, error, warning, info)
- Predefined styles for all UI elements
- Helper functions: `RenderBox()`, `RenderError()`, `RenderSuccess()`, etc.
- Uses Lipgloss for terminal styling

**Integration Flow:**

1. `main.go:main()` calls `tui.SelectManager()` based on environment
2. Manager passed to `run()` and `authenticate()`
3. OAuth flow functions use wrapper versions that send updates
4. Manager runs Bubble Tea program consuming update channel
5. On completion, Manager returns OAuth tokens to caller

**Design Benefits:**

- **No Breaking Changes**: Original OAuth logic untouched
- **Clean Separation**: UI and business logic decoupled
- **Easy Testing**: Can test OAuth without UI
- **Flexible**: Easy to add new UI modes
- **Environment Adaptive**: Auto-selects appropriate mode
- **User Control**: Flags override detection

## Key Implementation Details

### Public vs. Confidential Clients
Expand Down
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This mirrors the authentication strategy used by **GitHub CLI**, **Azure CLI**,
- [Why This CLI?](#why-this-cli)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Interactive Terminal UI](#interactive-terminal-ui)
- [Configuration](#configuration)
- [Authentication Flows](#authentication-flows)
- [Token Storage](#token-storage)
Expand Down Expand Up @@ -154,6 +155,90 @@ On each run the CLI follows this order:

---

## Interactive Terminal UI

Authgate CLI features a rich **interactive Terminal User Interface (TUI)** built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), providing visual feedback during OAuth authentication flows.

### Features

The TUI provides:

- **Visual Progress Indicators**: Step-by-step progress with animated spinners
- **Real-time Timers**: Countdown for browser flow, elapsed time for device flow
- **Progress Bars**: Visual representation of callback timeout
- **Polling Status**: Live updates showing device flow polling count and intervals
- **Backoff Warnings**: Clear notifications when server requests slower polling
- **Clean Layout**: Bordered boxes, color-coded messages, and structured information

### Browser Flow (Authorization Code + PKCE)

```
╭─────────────────────────────────────────────────╮
│ Authorization Code Flow with PKCE │
├─────────────────────────────────────────────────┤
│ │
│ ● Step 1/3: Opening browser ✓ │
│ ● Step 2/3: Waiting for callback ◐ │
│ ○ Step 3/3: Exchanging tokens │
│ │
│ Time remaining: 1:23 / 2:00 │
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░ 48% │
│ │
│ ◐ Please complete authorization in your browser │
│ │
│ Press Ctrl+C to cancel │
╰─────────────────────────────────────────────────╯
```

### Device Flow (Device Authorization Grant)

```
╭─────────────────────────────────────────────────╮
│ Device Authorization Grant Flow │
├─────────────────────────────────────────────────┤
│ │
│ ╔═══════════════════════════════════════════╗ │
│ ║ Device Authorization ║ │
│ ║ ║ │
│ ║ Visit: https://auth.example.com/device ║ │
│ ║ ?user_code=ABCD-EFGH ║ │
│ ║ ║ │
│ ║ Or go to: https://auth.example.com ║ │
│ ║ And enter: ABCD-EFGH ║ │
│ ╚═══════════════════════════════════════════╝ │
│ │
│ ◐ Waiting for authorization... (poll #8, 5s) │
│ │
│ Elapsed: 0:43 │
│ │
│ Press Ctrl+C to cancel │
╰─────────────────────────────────────────────────╯
```

### UI Mode Selection

The CLI automatically chooses the appropriate UI mode:

**Interactive TUI Mode** (default):

- Normal terminal with sufficient size (≥60x20)
- TTY detected
- TERM environment variable set (not "dumb")

**Simple Printf Mode** (automatic fallback):

- CI environments (GitHub Actions, GitLab CI, CircleCI, etc.)
- Output piped to file or another command
- Terminal too small (< 60 columns or < 20 rows)
- `TERM=dumb` or TERM unset
- SSH session without display forwarding
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI auto-selection bullets include “SSH session without display forwarding”, but shouldUseSimpleUI() doesn’t check SSH/DISPLAY; that condition currently affects browser flow availability (checkBrowserAvailability), not UI mode. Consider either implementing this check in shouldUseSimpleUI or adjusting the documentation to match the actual behavior.

Suggested change
- SSH session without display forwarding

Copilot uses AI. Check for mistakes.

### Note on UI Selection

The CLI automatically detects the environment and selects the appropriate UI mode. No configuration or flags are needed - it just works.

Comment on lines +236 to +239
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section states "No configuration or flags are needed", but the PR description mentions --simple-ui / --interactive-ui overrides. There’s no evidence of those flags in the codebase currently—either implement the flags or adjust the documentation/PR description so they’re consistent.

Copilot uses AI. Check for mistakes.
---

## Configuration

Configuration is resolved in priority order: **CLI flag → environment variable → default**.
Expand Down
132 changes: 132 additions & 0 deletions browser_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net/url"
"strings"
"time"

"github.com/go-authgate/cli/tui"
)

// performBrowserFlow runs the Authorization Code Flow with PKCE.
Expand Down Expand Up @@ -162,3 +164,133 @@ func exchangeCode(ctx context.Context, code, codeVerifier string) (*TokenStorage
ClientID: clientID,
}, nil
}

// performBrowserFlowWithUpdates runs the Authorization Code Flow with PKCE
// and sends progress updates through the provided channel.
//
// 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 performBrowserFlowWithUpdates(
ctx context.Context,
updates chan<- tui.FlowUpdate,
) (*tui.TokenStorage, bool, error) {
updates <- tui.FlowUpdate{
Type: tui.StepStart,
Step: 1,
TotalSteps: 3,
Message: "Generating PKCE parameters",
}

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

pkce, err := GeneratePKCE()
if err != nil {
Comment on lines +187 to +192
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On early failures (e.g., generateState() / GeneratePKCE()), this function returns an error without emitting a StepError update. In interactive mode, the Bubble Tea model only reacts to FlowUpdates, so the UI can hang indefinitely. Ensure all exit paths send a terminal update (StepError/StepComplete) before returning.

Suggested change
if err != nil {
return nil, false, fmt.Errorf("failed to generate state: %w", err)
}
pkce, err := GeneratePKCE()
if err != nil {
if err != nil {
updates <- tui.FlowUpdate{
Type: tui.StepError,
Message: fmt.Sprintf("Failed to generate state: %v", err),
}
return nil, false, fmt.Errorf("failed to generate state: %w", err)
}
pkce, err := GeneratePKCE()
if err != nil {
updates <- tui.FlowUpdate{
Type: tui.StepError,
Message: fmt.Sprintf("Failed to generate PKCE: %v", err),
}

Copilot uses AI. Check for mistakes.
return nil, false, fmt.Errorf("failed to generate PKCE: %w", err)
}

authURL := buildAuthURL(state, pkce)
updates <- tui.FlowUpdate{
Type: tui.StepStart,
Step: 1,
TotalSteps: 3,
Message: "Opening browser",
Data: map[string]interface{}{
"url": authURL,
},
}

if err := openBrowser(ctx, authURL); err != nil {
// Browser failed to open — signal the caller to fall back immediately.
updates <- tui.FlowUpdate{
Type: tui.StepError,
Message: fmt.Sprintf("Could not open browser: %v", err),
}
return nil, false, nil
}

updates <- tui.FlowUpdate{Type: tui.BrowserOpened}
updates <- tui.FlowUpdate{
Type: tui.StepStart,
Step: 2,
TotalSteps: 3,
Message: "Waiting for callback",
Data: map[string]interface{}{
"port": callbackPort,
},
}

// Start goroutine to send timer updates
done := make(chan struct{})
defer close(done)

go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
startTime := time.Now()

for {
select {
case <-done:
return
case <-ticker.C:
elapsed := time.Since(startTime)
progress := float64(elapsed) / float64(callbackTimeout)
if progress > 1.0 {
progress = 1.0
}
updates <- tui.FlowUpdate{
Type: tui.TimerTick,
Progress: progress,
Data: map[string]interface{}{
"elapsed": elapsed,
Comment on lines +224 to +250
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer goroutine can block sending to the updates channel; if the manager closes updates while this goroutine is mid-send, it can panic (send on closed channel) or leak. Consider making the send non-blocking (select on ctx/done), or have the manager own timer ticks instead of spawning an extra sender inside the flow.

Copilot uses AI. Check for mistakes.
"timeout": callbackTimeout,
},
}
}
}
}()

storage, err := startCallbackServer(ctx, callbackPort, state,
func(callbackCtx context.Context, code string) (*TokenStorage, error) {
updates <- tui.FlowUpdate{
Type: tui.StepStart,
Step: 3,
TotalSteps: 3,
Message: "Exchanging tokens",
}
return exchangeCode(callbackCtx, code, pkce.Verifier)
})
if err != nil {
if errors.Is(err, ErrCallbackTimeout) {
updates <- tui.FlowUpdate{
Type: tui.StepError,
Message: "Browser authorization timed out",
}
return nil, false, nil
}
return nil, false, fmt.Errorf("authentication failed: %w", err)
}

updates <- tui.FlowUpdate{Type: tui.CallbackReceived}
storage.Flow = "browser"

if err := saveTokens(storage); err != nil {
updates <- tui.FlowUpdate{
Type: tui.StepError,
Message: fmt.Sprintf("Warning: Failed to save tokens: %v", err),
}
}

updates <- tui.FlowUpdate{
Type: tui.StepComplete,
Step: 3,
TotalSteps: 3,
}

return toTUITokenStorage(storage), true, nil
}
Loading
Loading