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