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.
This commit is contained in:
patriceckhart 2026-04-22 17:49:11 +02:00
parent 9cf29463b8
commit 6b03aa3320
5 changed files with 313 additions and 15 deletions

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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",

View file

@ -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