From 6b03aa33207bebe82ca668e1b2085ee8c9d8aceb Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Wed, 22 Apr 2026 17:49:11 +0200 Subject: [PATCH] feat(auth): headless OAuth with paste-code input Runs the local callback server and the manual copy-code flow in parallel. Displays a real input field (with blinking cursor) for pasting the authorization code / redirect URL / code#state. Anthropic manual variant uses the console copy-code redirect URI to bypass localhost. --- internal/agent/modes/interactive.go | 50 +++++++++- internal/agent/modes/login_dialog.go | 117 +++++++++++++++++++++--- internal/auth/manager.go | 131 +++++++++++++++++++++++++++ internal/auth/oauth.go | 26 +++++- internal/tui/editor.go | 4 + 5 files changed, 313 insertions(+), 15 deletions(-) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 0d8a751..b6018d1 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -866,6 +866,12 @@ func (i *Interactive) redraw() { cursorCol = c } } + if i.dialog.Active() { + if r, c := i.dialog.CursorPos(cols); r >= 0 { + cursorRow = len(visibleChat) + r + cursorCol = c + } + } if i.extPanel.Active() { cursorRow = -1 cursorCol = 0 @@ -1087,6 +1093,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { if act.StartOAuth { i.startOAuthFlow(act.Provider) } + if act.StartManual { + i.startManualOAuthFlow(act.Provider) + } + if act.SubmitCode != "" { + i.submitManualOAuthCode(act.SubmitCode) + } return false } if i.modelDialog.Active() { @@ -1916,12 +1928,46 @@ func (i *Interactive) startAPIKeyFlow(provider string) { } func (i *Interactive) startOAuthFlow(provider string) { - url, err := i.cfg.AuthManager.StartOAuth(provider) + // Always run the manual/copy-code flow in parallel with the local + // callback server so headless environments (docker, SSH) can paste + // the authorization code directly without first pressing 'p'. + _, err := i.cfg.AuthManager.StartOAuth(provider) if err != nil { i.dialog.ShowResult(false, err.Error()) return } - i.dialog.ShowWaiting(url) + manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider) + if mErr == nil { + i.dialog.ShowWaiting(manualURL) + } else { + i.dialog.ShowResult(false, mErr.Error()) + } +} + +func (i *Interactive) startManualOAuthFlow(provider string) { + if i.cfg.AuthManager == nil { + return + } + i.cfg.AuthManager.CancelOAuth() + url, err := i.cfg.AuthManager.StartManualOAuth(provider) + if err != nil { + i.dialog.ShowResult(false, err.Error()) + return + } + i.dialog.url = url + i.invalidate() +} + +func (i *Interactive) submitManualOAuthCode(code string) { + if i.cfg.AuthManager == nil { + return + } + go func() { + if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil { + i.dialog.ShowResult(false, err.Error()) + i.invalidate() + } + }() } // applyModelSelection switches the active model (and provider, if the diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go index 11167fb..fdae545 100644 --- a/internal/agent/modes/login_dialog.go +++ b/internal/agent/modes/login_dialog.go @@ -15,11 +15,12 @@ type loginStep int // dialog must default to closed so nothing shows up until Open() is // explicitly called. const ( - loginStepClosed loginStep = iota - loginStepMethod // pick apikey vs subscription - loginStepProvider // pick anthropic vs openai - loginStepWaiting // browser open, waiting for callback - loginStepDone // success or error, waiting for key to dismiss + loginStepClosed loginStep = iota + loginStepMethod // pick apikey vs subscription + loginStepProvider // pick anthropic vs openai + loginStepWaiting // browser open, waiting for callback + loginStepPasteCode // user pastes the auth code here + loginStepDone // success or error, waiting for key to dismiss ) // loginDialog is a tiny inline dialog rendered above the editor while @@ -32,6 +33,7 @@ type loginDialog struct { success bool url string cursor int + codeEd *tui.Editor // status is a snapshot of the current login state for each // provider, captured when Open() runs. Rendered above the @@ -135,10 +137,47 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { lines = append(lines, frameRule(th, width)) case loginStepWaiting: lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider, width)) - lines = append(lines, th.FG256(th.FG, "opening browser...")) - lines = append(lines, th.FG256(th.Muted, d.url)) + lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) + wrapW := width - 2 + if wrapW < 20 { + wrapW = 20 + } + for _, seg := range tui.WrapANSILine(d.url, wrapW) { + lines = append(lines, th.FG256(th.Accent, seg)) + } lines = append(lines, "") - lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel.")) + lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) + if d.codeEd == nil { + d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) + } + edLines, _, _ := d.codeEd.Render(width - 2) + for _, l := range edLines { + lines = append(lines, l) + } + lines = append(lines, "") + lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels - waiting for browser callback in background")) + lines = append(lines, frameRule(th, width)) + case loginStepPasteCode: + lines = append(lines, frameHeader(th, "login - "+d.method+" - "+d.provider+" - paste code", width)) + lines = append(lines, th.FG256(th.Muted, "open this URL in any browser:")) + wrapW := width - 2 + if wrapW < 20 { + wrapW = 20 + } + for _, seg := range tui.WrapANSILine(d.url, wrapW) { + lines = append(lines, th.FG256(th.Accent, seg)) + } + lines = append(lines, "") + lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) + if d.codeEd == nil { + d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) + } + edLines, _, _ := d.codeEd.Render(width - 2) + for _, l := range edLines { + lines = append(lines, l) + } + lines = append(lines, "") + lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels")) lines = append(lines, frameRule(th, width)) case loginStepDone: title := "login - failed" @@ -197,8 +236,10 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { type loginDialogAction struct { StartAPIKey bool StartOAuth bool + StartManual bool Provider string Close bool + SubmitCode string } // HandleKey advances the dialog and returns an action to apply, if any. @@ -209,10 +250,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { case loginStepProvider: return d.handleProviderKey(k) case loginStepWaiting: - if k.Kind == tui.KeyEsc { - d.Close() - return loginDialogAction{Close: true} - } + return d.handleWaitingKey(k) + case loginStepPasteCode: + return d.handlePasteCodeKey(k) case loginStepDone: d.Close() return loginDialogAction{Close: true} @@ -292,6 +332,59 @@ func (d *loginDialog) ShowResult(success bool, message string) { d.message = message } +func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { + if k.Kind == tui.KeyEsc { + d.Close() + return loginDialogAction{Close: true} + } + if d.codeEd == nil { + return loginDialogAction{} + } + if submit := d.codeEd.HandleKey(k); submit { + code := d.codeEd.SubmitValue() + d.codeEd.Clear() + return loginDialogAction{SubmitCode: code} + } + return loginDialogAction{} +} + +func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { + if k.Kind == tui.KeyEsc { + d.Close() + return loginDialogAction{Close: true} + } + if d.codeEd == nil { + return loginDialogAction{} + } + if submit := d.codeEd.HandleKey(k); submit { + code := d.codeEd.SubmitValue() + d.codeEd.Clear() + return loginDialogAction{SubmitCode: code} + } + return loginDialogAction{} +} + +// CursorPos returns the absolute row/col inside the dialog where the +// terminal cursor should sit (paste-code step). Returns -1, -1 if the +// dialog is not in an input-expecting state. The host uses this to +// place the real blinking cursor on the code input. +func (d *loginDialog) CursorPos(width int) (row, col int) { + if d.codeEd == nil { + return -1, -1 + } + if d.step != loginStepPasteCode && d.step != loginStepWaiting { + return -1, -1 + } + _, eRow, eCol := d.codeEd.Render(width - 2) + wrapW := width - 2 + if wrapW < 20 { + wrapW = 20 + } + urlLines := len(tui.WrapANSILine(d.url, wrapW)) + baseOffset := 1 /*frameHeader*/ + 1 /*hint*/ + urlLines + 1 /*blank*/ + 1 /*prompt*/ + return baseOffset + eRow, eCol +} + func max0(x int) int { if x < 0 { return 0 diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 0f15040..a92a6f3 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -3,8 +3,11 @@ package auth import ( "context" "fmt" + "net/url" + "os" "os/exec" "runtime" + "strings" "sync" "time" ) @@ -30,6 +33,10 @@ type Manager struct { oauthCtx context.Context oauthCancel context.CancelFunc + + manualOp *OAuthProvider + manualPKCE PKCE + manualState string } // NewManager returns a Manager bound to store. @@ -196,6 +203,130 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *Callback m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) } +// StartManualOAuth begins an OAuth flow but does NOT start a local +// callback server or open a browser. The returned URL is shown to the +// user so they can complete the authorization on another device; the +// resulting code is pasted back via CompleteManualOAuth. +func (m *Manager) StartManualOAuth(provider string) (string, error) { + var op OAuthProvider + switch provider { + case "anthropic": + op = AnthropicManualOAuth + case "openai": + op = OpenAIOAuth + default: + return "", fmt.Errorf("provider must be anthropic or openai") + } + + pkce, err := NewPKCE() + if err != nil { + return "", err + } + authURL, state, err := op.AuthorizeURL(pkce) + if err != nil { + return "", err + } + + m.mu.Lock() + m.manualOp = &op + m.manualPKCE = pkce + m.manualState = state + m.mu.Unlock() + + m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) + return authURL, nil +} + +// CompleteManualOAuth exchanges the user-pasted authorization code for +// a token and stores it. Accepts either a raw code or a "code#state" +// token shown by providers like Anthropic when code=true is set. +func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { + m.mu.Lock() + op := m.manualOp + pkce := m.manualPKCE + state := m.manualState + m.mu.Unlock() + if op == nil { + return fmt.Errorf("no manual oauth flow in progress") + } + code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) + if pastedState != "" { + state = pastedState + } + if code == "" { + return fmt.Errorf("empty code") + } + exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + tok, err := op.Exchange(exCtx, code, state, pkce) + if err != nil { + m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + return err + } + if err := m.store.SetOAuth(op.Name, *tok); err != nil { + m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) + return err + } + m.mu.Lock() + m.manualOp = nil + m.manualPKCE = PKCE{} + m.manualState = "" + m.mu.Unlock() + m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) + return nil +} + +// parseManualCodeInput accepts any of: +// - a bare authorization code +// - a "code#state" pair +// - a full redirect URL like http(s)://host:port/callback?code=X&state=Y +// +// and returns the extracted code and (if any) state. +func parseManualCodeInput(s string) (code, state string) { + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + if u, err := url.Parse(s); err == nil { + q := u.Query() + return q.Get("code"), q.Get("state") + } + } + if idx := strings.IndexByte(s, '#'); idx >= 0 { + return s[:idx], s[idx+1:] + } + return s, "" +} + +// HasBrowser reports whether the current environment probably has a +// working interactive browser reachable from localhost. Used by the +// login flow to auto-switch to paste-code mode on headless boxes +// (containers, SSH without display forwarding, etc.) instead of +// trying to bind a callback port the user can never reach. +func HasBrowser() bool { + if os.Getenv("ZOT_NO_BROWSER") != "" { + return false + } + if os.Getenv("ZOT_FORCE_BROWSER") != "" { + return true + } + if _, err := os.Stat("/.dockerenv"); err == nil { + return false + } + if b, err := os.ReadFile("/proc/1/cgroup"); err == nil { + txt := string(b) + if strings.Contains(txt, "docker") || strings.Contains(txt, "kubepods") || strings.Contains(txt, "containerd") { + return false + } + } + switch runtime.GOOS { + case "darwin", "windows": + return true + default: + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + return false + } + return true + } +} + // CancelOAuth aborts any in-flight OAuth flow. func (m *Manager) CancelOAuth() { m.mu.Lock() diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 0746b16..75ed0e9 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -44,8 +44,13 @@ type OAuthProvider struct { IncludeStateInTokenRequest bool } -// RedirectURI returns the full redirect URI for this provider. +// RedirectURI returns the full redirect URI for this provider. For the +// manual variants (no local callback server) RedirectPath is already an +// absolute https URL, in which case it's returned as-is. func (p OAuthProvider) RedirectURI() string { + if p.RedirectHost == "" && (strings.HasPrefix(p.RedirectPath, "http://") || strings.HasPrefix(p.RedirectPath, "https://")) { + return p.RedirectPath + } return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath) } @@ -71,6 +76,25 @@ var ( IncludeStateInTokenRequest: true, } + // Anthropic manual / headless variant: redirects to Anthropic's + // copy-code page instead of a local loopback port, used when there + // is no browser on the machine running zot (e.g. inside a Docker + // container or over plain SSH). + AnthropicManualOAuth = OAuthProvider{ + Name: "anthropic", + AuthURL: "https://claude.ai/oauth/authorize", + TokenURL: "https://console.anthropic.com/v1/oauth/token", + ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + Scopes: []string{"org:create_api_key", "user:profile", "user:inference"}, + RedirectPath: "https://console.anthropic.com/oauth/code/callback", + ExtraAuthArgs: map[string]string{ + "code": "true", + }, + TokenBodyJSON: true, + StateEqualsVerifier: true, + IncludeStateInTokenRequest: true, + } + // OpenAI ChatGPT subscription: used by Codex CLI. OpenAIOAuth = OAuthProvider{ Name: "openai", diff --git a/internal/tui/editor.go b/internal/tui/editor.go index c0b8da1..3639019 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -829,6 +829,10 @@ func stripANSI(s string) string { return string(out) } +// WrapANSILine is the exported form of wrapANSILine so other modes / +// dialogs can reuse the same visible-width-aware wrap behavior. +func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) } + // wrapANSILine folds a string that may contain ANSI CSI escapes so that // the visible width of each line stays within limit. Breaks happen on // spaces when possible, falling back to mid-token splits for very long