mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
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:
parent
9cf29463b8
commit
6b03aa3320
5 changed files with 313 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue