oauth.go

  1package claude
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/url"
  7
  8	"charm.land/bubbles/v2/spinner"
  9	"charm.land/bubbles/v2/textinput"
 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/claude"
 14	"github.com/charmbracelet/crush/internal/tui/styles"
 15	"github.com/charmbracelet/crush/internal/tui/util"
 16	"github.com/pkg/browser"
 17	"github.com/zeebo/xxh3"
 18)
 19
 20type OAuthState int
 21
 22const (
 23	OAuthStateURL OAuthState = iota
 24	OAuthStateCode
 25)
 26
 27type OAuthValidationState int
 28
 29const (
 30	OAuthValidationStateNone OAuthValidationState = iota
 31	OAuthValidationStateVerifying
 32	OAuthValidationStateValid
 33	OAuthValidationStateError
 34)
 35
 36type ValidationCompletedMsg struct {
 37	State OAuthValidationState
 38	Token *oauth.Token
 39}
 40
 41type AuthenticationCompleteMsg struct{}
 42
 43type OAuth2 struct {
 44	State           OAuthState
 45	ValidationState OAuthValidationState
 46	width           int
 47	isOnboarding    bool
 48
 49	// URL page
 50	err       error
 51	verifier  string
 52	challenge string
 53	URL       string
 54	urlId     string
 55	token     *oauth.Token
 56
 57	// Code input page
 58	CodeInput textinput.Model
 59	spinner   spinner.Model
 60}
 61
 62func NewOAuth2() *OAuth2 {
 63	return &OAuth2{
 64		State: OAuthStateURL,
 65	}
 66}
 67
 68func (o *OAuth2) Init() tea.Cmd {
 69	t := styles.CurrentTheme()
 70
 71	verifier, challenge, err := claude.GetChallenge()
 72	if err != nil {
 73		o.err = err
 74		return nil
 75	}
 76
 77	url, err := claude.AuthorizeURL(verifier, challenge)
 78	if err != nil {
 79		o.err = err
 80		return nil
 81	}
 82
 83	o.verifier = verifier
 84	o.challenge = challenge
 85	o.URL = url
 86
 87	h := xxh3.New()
 88	_, _ = h.WriteString(o.URL)
 89	o.urlId = fmt.Sprintf("id=%x", h.Sum(nil))
 90
 91	o.CodeInput = textinput.New()
 92	o.CodeInput.Placeholder = "Paste or type"
 93	o.CodeInput.SetVirtualCursor(false)
 94	o.CodeInput.Prompt = "> "
 95	o.CodeInput.SetStyles(t.S().TextInput)
 96	o.CodeInput.SetWidth(50)
 97
 98	o.spinner = spinner.New(
 99		spinner.WithSpinner(spinner.Dot),
100		spinner.WithStyle(t.S().Base.Foreground(t.Green)),
101	)
102
103	return nil
104}
105
106func (o *OAuth2) Update(msg tea.Msg) (util.Model, tea.Cmd) {
107	var cmds []tea.Cmd
108
109	switch msg := msg.(type) {
110	case ValidationCompletedMsg:
111		o.ValidationState = msg.State
112		o.token = msg.Token
113		switch o.ValidationState {
114		case OAuthValidationStateError:
115			o.CodeInput.Focus()
116		}
117		o.updatePrompt()
118	}
119
120	if o.ValidationState == OAuthValidationStateVerifying {
121		var cmd tea.Cmd
122		o.spinner, cmd = o.spinner.Update(msg)
123		cmds = append(cmds, cmd)
124		o.updatePrompt()
125	}
126	{
127		var cmd tea.Cmd
128		o.CodeInput, cmd = o.CodeInput.Update(msg)
129		cmds = append(cmds, cmd)
130	}
131
132	return o, tea.Batch(cmds...)
133}
134
135func (o *OAuth2) ValidationConfirm() (util.Model, tea.Cmd) {
136	var cmds []tea.Cmd
137
138	switch {
139	case o.State == OAuthStateURL:
140		_ = browser.OpenURL(o.URL)
141		o.State = OAuthStateCode
142		cmds = append(cmds, o.CodeInput.Focus())
143	case o.ValidationState == OAuthValidationStateNone || o.ValidationState == OAuthValidationStateError:
144		o.CodeInput.Blur()
145		o.ValidationState = OAuthValidationStateVerifying
146		cmds = append(cmds, o.spinner.Tick, o.validateCode)
147	case o.ValidationState == OAuthValidationStateValid:
148		cmds = append(cmds, func() tea.Msg { return AuthenticationCompleteMsg{} })
149	}
150
151	o.updatePrompt()
152	return o, tea.Batch(cmds...)
153}
154
155func (o *OAuth2) View() string {
156	t := styles.CurrentTheme()
157
158	whiteStyle := lipgloss.NewStyle().Foreground(t.White)
159	primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
160	successStyle := lipgloss.NewStyle().Foreground(t.Success)
161	errorStyle := lipgloss.NewStyle().Foreground(t.Error)
162
163	titleStyle := whiteStyle
164	if o.isOnboarding {
165		titleStyle = primaryStyle
166	}
167
168	switch {
169	case o.err != nil:
170		return lipgloss.NewStyle().
171			Margin(0, 1).
172			Foreground(t.Error).
173			Render(o.err.Error())
174	case o.State == OAuthStateURL:
175		heading := lipgloss.
176			NewStyle().
177			Margin(0, 1).
178			Render(titleStyle.Render("Press enter key to open the following ") + successStyle.Render("URL") + titleStyle.Render(":"))
179
180		return lipgloss.JoinVertical(
181			lipgloss.Left,
182			heading,
183			"",
184			lipgloss.NewStyle().
185				Margin(0, 1).
186				Foreground(t.FgMuted).
187				Hyperlink(o.URL, o.urlId).
188				Render(o.displayUrl()),
189		)
190	case o.State == OAuthStateCode:
191		var heading string
192
193		switch o.ValidationState {
194		case OAuthValidationStateNone:
195			st := lipgloss.NewStyle().Margin(0, 1)
196			heading = st.Render(titleStyle.Render("Enter the ") + successStyle.Render("code") + titleStyle.Render(" you received."))
197		case OAuthValidationStateVerifying:
198			heading = titleStyle.Margin(0, 1).Render("Verifying...")
199		case OAuthValidationStateValid:
200			heading = successStyle.Margin(0, 1).Render("Validated.")
201		case OAuthValidationStateError:
202			heading = errorStyle.Margin(0, 1).Render("Invalid. Try again?")
203		}
204
205		return lipgloss.JoinVertical(
206			lipgloss.Left,
207			heading,
208			"",
209			" "+o.CodeInput.View(),
210		)
211	default:
212		panic("claude oauth2: invalid state")
213	}
214}
215
216func (o *OAuth2) SetDefaults() {
217	o.State = OAuthStateURL
218	o.ValidationState = OAuthValidationStateNone
219	o.CodeInput.SetValue("")
220	o.err = nil
221}
222
223func (o *OAuth2) SetWidth(w int) {
224	o.width = w
225	o.CodeInput.SetWidth(w - 4)
226}
227
228func (o *OAuth2) SetError(err error) {
229	o.err = err
230}
231
232func (o *OAuth2) validateCode() tea.Msg {
233	token, err := claude.ExchangeToken(context.Background(), o.CodeInput.Value(), o.verifier)
234	if err != nil || token == nil {
235		return ValidationCompletedMsg{State: OAuthValidationStateError}
236	}
237	return ValidationCompletedMsg{State: OAuthValidationStateValid, Token: token}
238}
239
240func (o *OAuth2) updatePrompt() {
241	switch o.ValidationState {
242	case OAuthValidationStateNone:
243		o.CodeInput.Prompt = "> "
244	case OAuthValidationStateVerifying:
245		o.CodeInput.Prompt = o.spinner.View() + " "
246	case OAuthValidationStateValid:
247		o.CodeInput.Prompt = styles.CheckIcon + " "
248	case OAuthValidationStateError:
249		o.CodeInput.Prompt = styles.ErrorIcon + " "
250	}
251}
252
253// Remove query params for display
254// e.g., "https://claude.ai/oauth/authorize?..." -> "https://claude.ai/oauth/authorize..."
255func (o *OAuth2) displayUrl() string {
256	parsed, err := url.Parse(o.URL)
257	if err != nil {
258		return o.URL
259	}
260
261	if parsed.RawQuery != "" {
262		parsed.RawQuery = ""
263		return parsed.String() + "..."
264	}
265
266	return o.URL
267}