feat(auth): add OAuth and token-based login for OpenAI and Anthropic

Add `picoclaw auth` CLI command supporting:
- OpenAI OAuth2 (PKCE + browser callback or device code flow)
- Anthropic paste-token flow
- Token storage at ~/.picoclaw/auth.json with 0600 permissions
- Auto-refresh for expired OAuth tokens in provider

Closes #18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cory LaNou
2026-02-11 11:41:13 -06:00
parent 6ccd9d0a99
commit 5efe8a2020
10 changed files with 1295 additions and 10 deletions

View File

@@ -15,13 +15,16 @@ import (
"net/http"
"strings"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
type HTTPProvider struct {
apiKey string
apiBase string
httpClient *http.Client
apiKey string
apiBase string
httpClient *http.Client
tokenSource func() (string, error)
accountID string
}
func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider {
@@ -73,9 +76,17 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
authHeader := "Bearer " + p.apiKey
req.Header.Set("Authorization", authHeader)
if p.tokenSource != nil {
token, err := p.tokenSource()
if err != nil {
return nil, fmt.Errorf("failed to get auth token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if p.accountID != "" {
req.Header.Set("Chatgpt-Account-Id", p.accountID)
}
} else if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.httpClient.Do(req)
@@ -170,6 +181,47 @@ func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
func createOAuthTokenSource(provider string) func() (string, error) {
return func() (string, error) {
cred, err := auth.GetCredential(provider)
if err != nil {
return "", fmt.Errorf("loading auth credentials: %w", err)
}
if cred == nil {
return "", fmt.Errorf("no OAuth credentials for %s. Run: picoclaw auth login --provider %s", provider, provider)
}
if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" {
oauthCfg := auth.OpenAIOAuthConfig()
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
if err != nil {
return "", fmt.Errorf("refreshing token: %w", err)
}
if err := auth.SetCredential(provider, refreshed); err != nil {
return "", fmt.Errorf("saving refreshed token: %w", err)
}
return refreshed.AccessToken, nil
}
return cred.AccessToken, nil
}
}
func createAuthProvider(providerName string, apiBase string) (LLMProvider, error) {
cred, err := auth.GetCredential(providerName)
if err != nil {
return nil, fmt.Errorf("loading auth credentials: %w", err)
}
if cred == nil {
return nil, fmt.Errorf("no credentials for %s. Run: picoclaw auth login --provider %s", providerName, providerName)
}
p := NewHTTPProvider(cred.AccessToken, apiBase)
p.tokenSource = createOAuthTokenSource(providerName)
p.accountID = cred.AccountID
return p, nil
}
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model := cfg.Agents.Defaults.Model
@@ -186,14 +238,28 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = "https://openrouter.ai/api/v1"
}
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && cfg.Providers.Anthropic.APIKey != "":
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
ab := cfg.Providers.Anthropic.APIBase
if ab == "" {
ab = "https://api.anthropic.com/v1"
}
return createAuthProvider("anthropic", ab)
}
apiKey = cfg.Providers.Anthropic.APIKey
apiBase = cfg.Providers.Anthropic.APIBase
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && cfg.Providers.OpenAI.APIKey != "":
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
ab := cfg.Providers.OpenAI.APIBase
if ab == "" {
ab = "https://api.openai.com/v1"
}
return createAuthProvider("openai", ab)
}
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
if apiBase == "" {