device_flow.go

  1// Package hyper provides the dialog for Hyper device flow authentication.
  2package hyper
  3
  4import (
  5	"context"
  6	"fmt"
  7	"time"
  8
  9	"charm.land/bubbles/v2/spinner"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
 13	"github.com/charmbracelet/crush/internal/oauth"
 14	"github.com/charmbracelet/crush/internal/oauth/hyper"
 15	"github.com/charmbracelet/crush/internal/tui/styles"
 16	"github.com/charmbracelet/crush/internal/tui/util"
 17	"github.com/pkg/browser"
 18)
 19
 20// DeviceFlowState represents the current state of the device flow.
 21type DeviceFlowState int
 22
 23const (
 24	DeviceFlowStateDisplay DeviceFlowState = iota
 25	DeviceFlowStateSuccess
 26	DeviceFlowStateError
 27)
 28
 29// DeviceAuthInitiatedMsg is sent when the device auth is initiated
 30// successfully.
 31type DeviceAuthInitiatedMsg struct {
 32	deviceCode string
 33	expiresIn  int
 34}
 35
 36// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
 37type DeviceFlowCompletedMsg struct {
 38	Token *oauth.Token
 39}
 40
 41// DeviceFlowErrorMsg is sent when the device flow encounters an error.
 42type DeviceFlowErrorMsg struct {
 43	Error error
 44}
 45
 46// DeviceFlow handles the Hyper device flow authentication.
 47type DeviceFlow struct {
 48	State           DeviceFlowState
 49	width           int
 50	baseURL         string
 51	deviceCode      string
 52	userCode        string
 53	verificationURL string
 54	expiresIn       int
 55	token           *oauth.Token
 56	cancelFunc      context.CancelFunc
 57	spinner         spinner.Model
 58}
 59
 60// NewDeviceFlow creates a new device flow component.
 61func NewDeviceFlow() *DeviceFlow {
 62	s := spinner.New()
 63	s.Spinner = spinner.Dot
 64	s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
 65	return &DeviceFlow{
 66		State:   DeviceFlowStateDisplay,
 67		baseURL: hyperp.BaseURL(),
 68		spinner: s,
 69	}
 70}
 71
 72// Init initializes the device flow by calling the device auth API and starting polling.
 73func (d *DeviceFlow) Init() tea.Cmd {
 74	return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
 75}
 76
 77// Update handles messages and state transitions.
 78func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 79	var cmd tea.Cmd
 80	d.spinner, cmd = d.spinner.Update(msg)
 81
 82	switch msg := msg.(type) {
 83	case DeviceAuthInitiatedMsg:
 84		// Start polling now that we have the device code.
 85		d.expiresIn = msg.expiresIn
 86		return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
 87	case DeviceFlowCompletedMsg:
 88		d.State = DeviceFlowStateSuccess
 89		d.token = msg.Token
 90		return d, nil
 91	case DeviceFlowErrorMsg:
 92		d.State = DeviceFlowStateError
 93		return d, util.ReportError(msg.Error)
 94	}
 95
 96	return d, cmd
 97}
 98
 99// View renders the device flow dialog.
100func (d *DeviceFlow) View() string {
101	t := styles.CurrentTheme()
102
103	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
104	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
105	greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
106	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
107	mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
108
109	switch d.State {
110	case DeviceFlowStateDisplay:
111		if d.userCode == "" {
112			return lipgloss.NewStyle().
113				Margin(0, 1).
114				Render(
115					greenStyle.Render(d.spinner.View()) +
116						mutedStyle.Render("Initializing..."),
117				)
118		}
119
120		instructions := lipgloss.NewStyle().
121			Margin(1, 1, 0, 1).
122			Width(d.width - 2).
123			Render(
124
125				whiteStyle.Render("Press ") +
126					primaryStyle.Render("enter") +
127					whiteStyle.Render(" to copy the code below and open the browser."),
128			)
129
130		codeBox := lipgloss.NewStyle().
131			Width(d.width-2).
132			Height(7).
133			Align(lipgloss.Center, lipgloss.Center).
134			Background(t.BgBaseLighter).
135			Margin(1).
136			Render(
137				lipgloss.NewStyle().
138					Bold(true).
139					Foreground(t.White).
140					Render(d.userCode),
141			)
142
143		link := lipgloss.NewStyle().Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
144		url := mutedStyle.
145			Margin(0, 1).
146			Width(d.width - 2).
147			Render("Browser not opening? Refer to\n" + link)
148
149		waiting := greenStyle.
150			Width(d.width-2).
151			Margin(1, 1, 0, 1).
152			Render(d.spinner.View() + "Verifying...")
153
154		return lipgloss.JoinVertical(
155			lipgloss.Left,
156			instructions,
157			codeBox,
158			url,
159			waiting,
160		)
161
162	case DeviceFlowStateSuccess:
163		return greenStyle.Margin(0, 1).Render("Authentication successful!")
164
165	case DeviceFlowStateError:
166		return lipgloss.NewStyle().
167			Margin(0, 1).
168			Width(d.width).
169			Render(errorStyle.Render("Authentication failed."))
170
171	default:
172		return ""
173	}
174}
175
176// SetWidth sets the width of the dialog.
177func (d *DeviceFlow) SetWidth(w int) {
178	d.width = w
179}
180
181// Cursor hides the cursor.
182func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
183
184// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
185func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
186	return tea.Sequence(
187		tea.SetClipboard(d.userCode),
188		func() tea.Msg {
189			if err := browser.OpenURL(d.verificationURL); err != nil {
190				return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
191			}
192			return nil
193		},
194		util.ReportInfo("Code copied and URL opened"),
195	)
196}
197
198// CopyCode copies just the user code to the clipboard.
199func (d *DeviceFlow) CopyCode() tea.Cmd {
200	return tea.Sequence(
201		tea.SetClipboard(d.userCode),
202		util.ReportInfo("Code copied to clipboard"),
203	)
204}
205
206// Cancel cancels the device flow polling.
207func (d *DeviceFlow) Cancel() {
208	if d.cancelFunc != nil {
209		d.cancelFunc()
210	}
211}
212
213func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
214	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
215	defer cancel()
216	authResp, err := hyper.InitiateDeviceAuth(ctx)
217	if err != nil {
218		return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
219	}
220
221	d.deviceCode = authResp.DeviceCode
222	d.userCode = authResp.UserCode
223	d.verificationURL = authResp.VerificationURL
224
225	return DeviceAuthInitiatedMsg{
226		deviceCode: authResp.DeviceCode,
227		expiresIn:  authResp.ExpiresIn,
228	}
229}
230
231// startPolling starts polling for the device token.
232func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
233	return func() tea.Msg {
234		ctx, cancel := context.WithCancel(context.Background())
235		d.cancelFunc = cancel
236
237		// Poll for refresh token.
238		refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
239		if err != nil {
240			if ctx.Err() != nil {
241				// Cancelled, don't report error.
242				return nil
243			}
244			return DeviceFlowErrorMsg{Error: err}
245		}
246
247		// Exchange refresh token for access token.
248		token, err := hyper.ExchangeToken(ctx, refreshToken)
249		if err != nil {
250			return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
251		}
252
253		// Verify the access token works.
254		introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
255		if err != nil {
256			return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
257		}
258		if !introspect.Active {
259			return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
260		}
261
262		return DeviceFlowCompletedMsg{Token: token}
263	}
264}