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
3 changes: 2 additions & 1 deletion auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -143,7 +144,7 @@ func makeAPICallWithAutoRefresh(

newStorage, err := refreshAccessToken(ctx, cfg, storage.RefreshToken)
if err != nil {
if err == ErrRefreshTokenExpired {
if errors.Is(err, ErrRefreshTokenExpired) {
return ErrRefreshTokenExpired
}
return fmt.Errorf("refresh failed: %w", err)
Expand Down
26 changes: 13 additions & 13 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,16 @@ const (

// AppConfig holds all resolved configuration for the CLI application.
type AppConfig struct {
ServerURL string
ClientID string
ClientSecret string
RedirectURI string
CallbackPort int
Scope string
ForceDevice bool
RetryClient *retry.Client
Store credstore.Store[credstore.Token]
ServerURL string
ClientID string
ClientSecret string
RedirectURI string
CallbackPort int
Scope string
ForceDevice bool
TokenStoreMode string // "auto", "file", or "keyring"
RetryClient *retry.Client
Store credstore.Store[credstore.Token]
}

// IsPublicClient returns true when no client secret is configured —
Expand Down Expand Up @@ -93,8 +94,8 @@ func loadStoreConfig() *AppConfig {
cfg := &AppConfig{}

cfg.ClientID = getConfig(flagClientID, "CLIENT_ID", "")
cfg.TokenStoreMode = getConfig(flagTokenStore, "TOKEN_STORE", "auto")
tokenFile := getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
tokenStoreMode := getConfig(flagTokenStore, "TOKEN_STORE", "auto")

if cfg.ClientID == "" {
fmt.Fprintln(os.Stderr, "Error: CLIENT_ID not set. Please provide it via:")
Expand All @@ -106,7 +107,7 @@ func loadStoreConfig() *AppConfig {
}

var storeErr error
cfg.Store, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService)
cfg.Store, storeErr = newTokenStore(cfg.TokenStoreMode, tokenFile, defaultKeyringService)
if storeErr != nil {
fmt.Fprintln(os.Stderr, storeErr)
os.Exit(1)
Expand Down Expand Up @@ -179,8 +180,7 @@ func loadConfig() *AppConfig {
panic(fmt.Sprintf("failed to create retry client: %v", err))
}

tokenStoreMode := getConfig(flagTokenStore, "TOKEN_STORE", "auto")
if tokenStoreMode == "auto" {
if cfg.TokenStoreMode == "auto" {
if ss, ok := cfg.Store.(*credstore.SecureStore[credstore.Token]); ok && !ss.UseKeyring() {
fmt.Fprintln(
os.Stderr,
Expand Down
67 changes: 0 additions & 67 deletions device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,73 +140,6 @@ func handleDevicePollError(
}
}

// pollForTokenWithProgress polls for a token while showing progress dots.
// Implements exponential backoff for slow_down errors per RFC 8628.
func pollForTokenWithProgress(
ctx context.Context,
cfg *AppConfig,
config *oauth2.Config,
deviceAuth *oauth2.DeviceAuthResponse,
) (*oauth2.Token, error) {
interval := deviceAuth.Interval
if interval == 0 {
interval = defaultPollInterval
}

uiUpdateInterval := 2 * time.Second
pollInterval := time.Duration(interval) * time.Second
backoffMultiplier := 1.0

ticker := time.NewTicker(uiUpdateInterval)
defer ticker.Stop()

pollTicker := time.NewTicker(pollInterval)
defer pollTicker.Stop()

dotCount := 0
lastUpdate := time.Now()

for {
select {
case <-ctx.Done():
fmt.Println()
return nil, ctx.Err()

case <-pollTicker.C:
token, err := exchangeDeviceCode(
ctx,
cfg,
config.Endpoint.TokenURL,
config.ClientID,
deviceAuth.DeviceCode,
)
if err != nil {
result := handleDevicePollError(err, &pollInterval, &backoffMultiplier, pollTicker)
switch result.action {
case pollContinue, pollBackoff:
continue
default:
fmt.Println()
return nil, result.err
}
}

fmt.Println()
return token, nil

case <-ticker.C:
if time.Since(lastUpdate) >= uiUpdateInterval {
fmt.Print(".")
dotCount++
lastUpdate = time.Now()
if dotCount%50 == 0 {
fmt.Println()
}
}
}
}
}

// exchangeDeviceCode exchanges a device code for an access token.
func exchangeDeviceCode(
ctx context.Context,
Expand Down
3 changes: 0 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ module github.com/go-authgate/cli
go 1.25.0

require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.2
github.com/appleboy/go-httpretry v0.11.0
github.com/go-authgate/sdk-go v0.2.0
Expand All @@ -19,7 +17,6 @@ require (
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
Expand Down
10 changes: 0 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/appleboy/go-httpretry v0.11.0 h1:LI2kFDBI9ghxIip9dJz3uRMEVEwSSOC1bjS177QCi+w=
github.com/appleboy/go-httpretry v0.11.0/go.mod h1:96v1IO6wg1+S10iFbOM3O8rn2vkFw8+uH4mDPhGoz+E=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
Expand Down
13 changes: 4 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int {
ui.ShowHeader(clientMode, cfg.ServerURL, cfg.ClientID)

var storage *credstore.Token
var flow string

// Try to reuse or refresh existing tokens.
existing, err := cfg.Store.Load(cfg.ClientID)
Expand All @@ -86,13 +87,15 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int {
if time.Now().Before(existing.ExpiresAt) {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenStillValid})
storage = &existing
flow = "cached"
} else {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenExpired})
newStorage, err := refreshAccessToken(ctx, cfg, existing.RefreshToken)
if err != nil {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshFailed, Err: err})
} else {
storage = newStorage
flow = "refreshed"
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshSuccess})
}
}
Expand All @@ -101,14 +104,6 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int {
}

// No valid tokens — select and run the appropriate flow.
var flow string
if storage != nil && err == nil {
if time.Now().Before(existing.ExpiresAt) {
flow = "cached"
} else {
flow = "refreshed"
}
}
if storage == nil {
storage, flow, err = authenticate(ctx, ui, cfg)
if err != nil {
Expand Down Expand Up @@ -140,7 +135,7 @@ func run(ctx context.Context, ui tui.Manager, cfg *AppConfig) int {
// Demonstrate auto-refresh on 401.
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventAutoRefreshDemo})
if err := makeAPICallWithAutoRefresh(ctx, cfg, storage, ui); err != nil {
if err == ErrRefreshTokenExpired {
if errors.Is(err, ErrRefreshTokenExpired) {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventRefreshTokenExpired})
storage, _, err = authenticate(ctx, ui, cfg)
if err != nil {
Expand Down
41 changes: 36 additions & 5 deletions polling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,30 @@ import (
"testing"
"time"

"github.com/go-authgate/cli/tui"
"golang.org/x/oauth2"
)

const testAccessToken = "test-access-token"

// drainUpdates consumes FlowUpdate messages in the background so the producer
// never blocks. It signals the goroutine to stop via a done channel on
// cleanup, without closing the producer-owned updates channel.
func drainUpdates(t *testing.T, ch <-chan tui.FlowUpdate) {
t.Helper()
done := make(chan struct{})
t.Cleanup(func() { close(done) })
go func() {
for {
select {
case <-ch:
case <-done:
return
}
}
}()
}

func TestPollForToken_AuthorizationPending(t *testing.T) {
attempts := atomic.Int32{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -58,7 +77,10 @@ func TestPollForToken_AuthorizationPending(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

token, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth)
updates := make(chan tui.FlowUpdate, 100)
drainUpdates(t, updates)

token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
Expand Down Expand Up @@ -128,7 +150,10 @@ func TestPollForToken_SlowDown(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

token, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth)
updates := make(chan tui.FlowUpdate, 100)
drainUpdates(t, updates)

token, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
Expand All @@ -140,7 +165,7 @@ func TestPollForToken_SlowDown(t *testing.T) {
}
}

// pollForTokenErrorTest is a shared helper for tests that expect pollForTokenWithProgress
// pollForTokenErrorTest is a shared helper for tests that expect pollForTokenWithUpdates
// to return a specific error when the server responds with a terminal OAuth error code.
func pollForTokenErrorTest(t *testing.T, errCode, errDesc, expectedMsg string) {
t.Helper()
Expand Down Expand Up @@ -170,7 +195,10 @@ func pollForTokenErrorTest(t *testing.T, errCode, errDesc, expectedMsg string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

_, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth)
updates := make(chan tui.FlowUpdate, 100)
drainUpdates(t, updates)

_, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates)
if err == nil {
t.Fatal("expected error, got nil")
}
Expand Down Expand Up @@ -222,7 +250,10 @@ func TestPollForToken_ContextTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

_, err := pollForTokenWithProgress(ctx, cfg, config, deviceAuth)
updates := make(chan tui.FlowUpdate, 100)
drainUpdates(t, updates)

_, err := pollForTokenWithUpdates(ctx, cfg, config, deviceAuth, updates)
if err == nil {
t.Fatal("expected context timeout error, got nil")
}
Expand Down
14 changes: 13 additions & 1 deletion tui/bubbletea_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ func (m *BubbleTeaManager) ShowFlowSelection(method string) {
m.refreshDisplay()
}

// Helper methods for managing steps
// Helper methods for managing steps.
// addStep is idempotent — calling it with the same name twice is a no-op.
func (m *BubbleTeaManager) addStep(name string) {
if m.renderer != nil {
if _, exists := m.stepMap[name]; exists {
return
}
idx := m.renderer.AddStep(name)
m.stepMap[name] = idx
}
Expand Down Expand Up @@ -393,7 +397,15 @@ func (m *BubbleTeaManager) ShowStatus(update StatusUpdate) {
func (m *BubbleTeaManager) ShowUserInfo(success bool, info string) {
if m.renderer == nil {
m.simple.ShowUserInfo(success, info)
return
}
m.addStep("OIDC UserInfo")
if success {
m.updateStep("OIDC UserInfo", StepCompleted, "Profile retrieved")
} else {
m.updateStep("OIDC UserInfo", StepFailed, info)
}
m.refreshDisplay()
}

// getTerminalSize returns the width and height of the terminal.
Expand Down
Loading
Loading