feat(auth): add Anthropic setup-token authentication

Add support for Claude CLI setup-tokens (sk-ant-oat01-*) as an
authentication method for the Anthropic provider, complementing the
existing API key paste flow.

Changes:
- pkg/auth/token.go: add LoginSetupToken() with prefix and length
  validation, keeping LoginPasteToken untouched
- pkg/providers/claude_provider.go: add NewClaudeProviderWithBearerToken()
  that uses Authorization Bearer with required headers (anthropic-beta:
  claude-code-20250219,oauth-2025-04-20, user-agent, x-app) to enable
  OAuth on the Anthropic API; add useAPIKey field to distinguish between
  x-api-key (regular keys) and Bearer (setup-token/oauth) at request time
- pkg/providers/http_provider.go: route setup-token credentials through
  the Bearer provider; recognize "setup-token" as valid auth method in
  both provider selection paths
- cmd/picoclaw/main.go: add --setup-token CLI flag for anthropic login,
  new authLoginSetupToken() handler, updated help text

Usage: picoclaw auth login --provider anthropic --setup-token

Author: Edgar Gomero
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
edgargomero 2026-02-15 06:01:44 -03:00
parent 9a3f3611c3
commit 32a5bf841c
4 changed files with 97 additions and 6 deletions

View file

@ -777,11 +777,13 @@ func authHelp() {
fmt.Println("Login options:") fmt.Println("Login options:")
fmt.Println(" --provider <name> Provider to login with (openai, anthropic)") fmt.Println(" --provider <name> Provider to login with (openai, anthropic)")
fmt.Println(" --device-code Use device code flow (for headless environments)") fmt.Println(" --device-code Use device code flow (for headless environments)")
fmt.Println(" --setup-token Use Claude CLI setup-token (anthropic only)")
fmt.Println() fmt.Println()
fmt.Println("Examples:") fmt.Println("Examples:")
fmt.Println(" picoclaw auth login --provider openai") fmt.Println(" picoclaw auth login --provider openai")
fmt.Println(" picoclaw auth login --provider openai --device-code") fmt.Println(" picoclaw auth login --provider openai --device-code")
fmt.Println(" picoclaw auth login --provider anthropic") fmt.Println(" picoclaw auth login --provider anthropic")
fmt.Println(" picoclaw auth login --provider anthropic --setup-token")
fmt.Println(" picoclaw auth logout --provider openai") fmt.Println(" picoclaw auth logout --provider openai")
fmt.Println(" picoclaw auth status") fmt.Println(" picoclaw auth status")
} }
@ -789,6 +791,7 @@ func authHelp() {
func authLoginCmd() { func authLoginCmd() {
provider := "" provider := ""
useDeviceCode := false useDeviceCode := false
useSetupToken := false
args := os.Args[3:] args := os.Args[3:]
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
@ -800,6 +803,8 @@ func authLoginCmd() {
} }
case "--device-code": case "--device-code":
useDeviceCode = true useDeviceCode = true
case "--setup-token":
useSetupToken = true
} }
} }
@ -813,13 +818,40 @@ func authLoginCmd() {
case "openai": case "openai":
authLoginOpenAI(useDeviceCode) authLoginOpenAI(useDeviceCode)
case "anthropic": case "anthropic":
if useSetupToken {
authLoginSetupToken()
} else {
authLoginPasteToken(provider) authLoginPasteToken(provider)
}
default: default:
fmt.Printf("Unsupported provider: %s\n", provider) fmt.Printf("Unsupported provider: %s\n", provider)
fmt.Println("Supported providers: openai, anthropic") fmt.Println("Supported providers: openai, anthropic")
} }
} }
func authLoginSetupToken() {
cred, err := auth.LoginSetupToken(os.Stdin)
if err != nil {
fmt.Printf("Login failed: %v\n", err)
os.Exit(1)
}
if err := auth.SetCredential("anthropic", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
appCfg.Providers.Anthropic.AuthMethod = "setup-token"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Println("Setup-token saved for Anthropic!")
}
func authLoginOpenAI(useDeviceCode bool) { func authLoginOpenAI(useDeviceCode bool) {
cfg := auth.OpenAIOAuthConfig() cfg := auth.OpenAIOAuthConfig()

View file

@ -31,6 +31,40 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
}, nil }, nil
} }
const anthropicSetupTokenPrefix = "sk-ant-oat01-"
func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
fmt.Println("Paste your setup-token from Claude CLI (claude setup-token):")
fmt.Print("> ")
scanner := bufio.NewScanner(r)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading setup-token: %w", err)
}
return nil, fmt.Errorf("no input received")
}
token := strings.TrimSpace(scanner.Text())
if token == "" {
return nil, fmt.Errorf("setup-token cannot be empty")
}
if !strings.HasPrefix(token, anthropicSetupTokenPrefix) {
return nil, fmt.Errorf("invalid setup-token: must start with %s", anthropicSetupTokenPrefix)
}
if len(token) < 80 {
return nil, fmt.Errorf("invalid setup-token: too short (expected at least 80 characters)")
}
return &AuthCredential{
AccessToken: token,
Provider: "anthropic",
AuthMethod: "setup-token",
}, nil
}
func providerDisplayName(provider string) string { func providerDisplayName(provider string) string {
switch provider { switch provider {
case "anthropic": case "anthropic":

View file

@ -13,14 +13,26 @@ import (
type ClaudeProvider struct { type ClaudeProvider struct {
client *anthropic.Client client *anthropic.Client
tokenSource func() (string, error) tokenSource func() (string, error)
useAPIKey bool
} }
func NewClaudeProvider(token string) *ClaudeProvider { func NewClaudeProvider(token string) *ClaudeProvider {
client := anthropic.NewClient( client := anthropic.NewClient(
option.WithAuthToken(token), option.WithAPIKey(token),
option.WithBaseURL("https://api.anthropic.com"), option.WithBaseURL("https://api.anthropic.com"),
) )
return &ClaudeProvider{client: &client} return &ClaudeProvider{client: &client, useAPIKey: true}
}
func NewClaudeProviderWithBearerToken(token string) *ClaudeProvider {
client := anthropic.NewClient(
option.WithAuthToken(token),
option.WithBaseURL("https://api.anthropic.com"),
option.WithHeader("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"),
option.WithHeader("user-agent", "claude-cli/2.1.2 (external, cli)"),
option.WithHeader("x-app", "cli"),
)
return &ClaudeProvider{client: &client, useAPIKey: false}
} }
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
@ -29,6 +41,12 @@ func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string,
return p return p
} }
func NewClaudeProviderWithTokenSourceBearer(token string, tokenSource func() (string, error)) *ClaudeProvider {
p := NewClaudeProviderWithBearerToken(token)
p.tokenSource = tokenSource
return p
}
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
var opts []option.RequestOption var opts []option.RequestOption
if p.tokenSource != nil { if p.tokenSource != nil {
@ -36,8 +54,12 @@ func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []T
if err != nil { if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err) return nil, fmt.Errorf("refreshing token: %w", err)
} }
if p.useAPIKey {
opts = append(opts, option.WithAPIKey(tok))
} else {
opts = append(opts, option.WithAuthToken(tok)) opts = append(opts, option.WithAuthToken(tok))
} }
}
params, err := buildClaudeParams(messages, tools, model, options) params, err := buildClaudeParams(messages, tools, model, options)
if err != nil { if err != nil {

View file

@ -205,6 +205,9 @@ func createClaudeAuthProvider() (LLMProvider, error) {
if cred == nil { if cred == nil {
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
} }
if cred.AuthMethod == "oauth" || cred.AuthMethod == "setup-token" {
return NewClaudeProviderWithTokenSourceBearer(cred.AccessToken, createClaudeTokenSource()), nil
}
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
} }
@ -251,7 +254,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
} }
case "anthropic", "claude": case "anthropic", "claude":
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" {
return createClaudeAuthProvider() return createClaudeAuthProvider()
} }
apiKey = cfg.Providers.Anthropic.APIKey apiKey = cfg.Providers.Anthropic.APIKey
@ -348,7 +351,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
} }
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): 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" { if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" || cfg.Providers.Anthropic.AuthMethod == "setup-token" {
return createClaudeAuthProvider() return createClaudeAuthProvider()
} }
apiKey = cfg.Providers.Anthropic.APIKey apiKey = cfg.Providers.Anthropic.APIKey