device_flow.go 6.9 KB

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