Skip to content

Commit 9f30f9b

Browse files
intel352claude
andcommitted
fix: restore GitHub device flow for Copilot auth
The device flow was failing because scope "copilot" is not a valid GitHub OAuth scope — Copilot access is subscription-based. Removed the scope parameter. Flow now: try gh auth token silently → if unavailable, launch proper device flow (show user code, open browser, poll for token) → fall back to PAT paste only if device flow fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aec5f77 commit 9f30f9b

2 files changed

Lines changed: 70 additions & 18 deletions

File tree

internal/provider/oauth.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ const (
4545
const (
4646
githubDeviceCodeURL = "https://github.com/login/device/code"
4747
githubTokenURL = "https://github.com/login/oauth/access_token"
48-
// GitHub Copilot's official OAuth App client ID.
49-
githubCopilotClientID = "Iv1.b507a08c87ecfe98"
48+
// GithubCopilotClientID is GitHub Copilot's official OAuth App client ID.
49+
GithubCopilotClientID = "Iv1.b507a08c87ecfe98"
5050
)
5151

5252
// TryGHToken attempts to get a GitHub token from the gh CLI.
@@ -206,7 +206,6 @@ func exchangeAnthropicCode(ctx context.Context, code, verifier, redirectURI stri
206206
func StartGitHubDeviceFlow(ctx context.Context, clientID string) (*DeviceCodeResult, error) {
207207
data := url.Values{
208208
"client_id": {clientID},
209-
"scope": {"copilot"},
210209
}
211210

212211
req, err := http.NewRequestWithContext(ctx, "POST", githubDeviceCodeURL, strings.NewReader(data.Encode()))

internal/tui/pages/onboarding.go

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ type ghTokenMsg struct {
4343
token string // empty if gh not available
4444
}
4545

46+
// deviceCodeMsg carries the result of a GitHub device code request.
47+
type deviceCodeMsg struct {
48+
result *providerauth.DeviceCodeResult
49+
err error
50+
}
51+
4652
type onboardingStep int
4753

4854
const (
@@ -116,12 +122,14 @@ type OnboardingModel struct {
116122
// Base URL input
117123
baseURLInput textinput.Model
118124

119-
// Browser auth state
120-
authing bool // browser/gh auth in progress
121-
authError string
122-
authToken string // token obtained from browser/gh auth
123-
browserOpened bool // browser was opened for user
124-
authCancel context.CancelFunc
125+
// Browser/device auth state
126+
authing bool // browser/gh/device auth in progress
127+
authError string
128+
authToken string // token obtained from auth flow
129+
browserOpened bool // browser was opened for user
130+
authCancel context.CancelFunc
131+
deviceUserCode string // device flow: code to display to user
132+
deviceVerificationURI string // device flow: URL to open
125133

126134
// Model selection
127135
modelCursor int
@@ -177,27 +185,47 @@ func (m OnboardingModel) Update(msg tea.Msg) (OnboardingModel, tea.Cmd) {
177185
return m, nil
178186

179187
case ghTokenMsg:
180-
m.authing = false
181188
if msg.token != "" {
182189
// gh CLI provided a token — skip to model selection
190+
m.authing = false
183191
m.authToken = msg.token
184192
m.step = stepSelectModel
185193
m.modelCursor = 0
186194
return m, nil
187195
}
188-
// gh not available — fall through to API key entry with instructions
189-
m.browserOpened = false
190-
m.step = stepEnterAPIKey
191-
m.apiKeyInput.Placeholder = "ghp_..."
192-
return m, m.apiKeyInput.Focus()
196+
// gh not available — start GitHub device flow
197+
return m, m.startDeviceFlow()
198+
199+
case deviceCodeMsg:
200+
if msg.err != nil {
201+
// Device flow request failed — fall to API key paste
202+
m.authing = false
203+
m.authError = msg.err.Error()
204+
m.step = stepEnterAPIKey
205+
m.apiKeyInput.Placeholder = "ghp_..."
206+
return m, m.apiKeyInput.Focus()
207+
}
208+
// Show user code and start polling
209+
m.deviceUserCode = msg.result.UserCode
210+
m.deviceVerificationURI = msg.result.VerificationURI
211+
m.browserOpened = true
212+
go providerauth.OpenBrowserURL(msg.result.VerificationURI) //nolint:errcheck
213+
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(msg.result.ExpiresIn)*time.Second)
214+
m.authCancel = cancel
215+
return m, m.pollDeviceFlow(ctx, msg.result.DeviceCode, msg.result.Interval)
193216

194217
case browserAuthResultMsg:
195218
m.authing = false
196219
if msg.err != nil {
197-
// OAuth failed — fall back to API key paste
220+
// Auth failed — fall back to API key paste
198221
m.authError = msg.err.Error()
199222
m.step = stepEnterAPIKey
200-
m.apiKeyInput.Placeholder = "sk-ant-..."
223+
p := m.selectedProvider()
224+
if p.auth == authGHCLI {
225+
m.apiKeyInput.Placeholder = "ghp_..."
226+
} else {
227+
m.apiKeyInput.Placeholder = "sk-ant-..."
228+
}
201229
return m, m.apiKeyInput.Focus()
202230
}
203231
m.authToken = msg.token
@@ -283,6 +311,8 @@ func (m OnboardingModel) advanceFromProvider() (OnboardingModel, tea.Cmd) {
283311
m.authToken = ""
284312
m.authError = ""
285313
m.browserOpened = false
314+
m.deviceUserCode = ""
315+
m.deviceVerificationURI = ""
286316

287317
switch p.auth {
288318
case authBrowser:
@@ -326,6 +356,21 @@ func (m OnboardingModel) tryGHToken() tea.Cmd {
326356
}
327357
}
328358

359+
func (m OnboardingModel) startDeviceFlow() tea.Cmd {
360+
return func() tea.Msg {
361+
result, err := providerauth.StartGitHubDeviceFlow(context.Background(), providerauth.GithubCopilotClientID)
362+
return deviceCodeMsg{result: result, err: err}
363+
}
364+
}
365+
366+
func (m OnboardingModel) pollDeviceFlow(ctx context.Context, deviceCode string, interval int) tea.Cmd {
367+
return func() tea.Msg {
368+
ch := providerauth.PollGitHubDeviceFlow(ctx, providerauth.GithubCopilotClientID, deviceCode, interval)
369+
result := <-ch
370+
return browserAuthResultMsg{token: result.Token, err: result.Err}
371+
}
372+
}
373+
329374
func (m OnboardingModel) startAnthropicAuth(ctx context.Context) tea.Cmd {
330375
return func() tea.Msg {
331376
ch := providerauth.StartAnthropicOAuth(ctx)
@@ -576,7 +621,15 @@ func (m OnboardingModel) View(t theme.Theme, width, height int) string {
576621
case stepBrowserAuth:
577622
p := m.selectedProvider()
578623
if m.authing {
579-
if p.auth == authGHCLI {
624+
if p.auth == authGHCLI && m.deviceUserCode != "" {
625+
// Device flow: show user code
626+
sb.WriteString("Sign in with GitHub Copilot\n\n")
627+
codeStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
628+
sb.WriteString("Your code: " + codeStyle.Render(m.deviceUserCode) + "\n\n")
629+
sb.WriteString(m.spinner.View() + " Waiting for authorization...\n\n")
630+
sb.WriteString(mutedStyle.Render("Enter the code at "+m.deviceVerificationURI) + "\n")
631+
sb.WriteString(mutedStyle.Render("Esc: cancel"))
632+
} else if p.auth == authGHCLI {
580633
sb.WriteString(m.spinner.View() + " Checking for GitHub CLI auth...\n")
581634
} else {
582635
sb.WriteString("Sign in with " + p.displayName + "\n\n")

0 commit comments

Comments
 (0)