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:
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
@@ -85,6 +86,8 @@ func main() {
|
||||
gatewayCmd()
|
||||
case "status":
|
||||
statusCmd()
|
||||
case "auth":
|
||||
authCmd()
|
||||
case "cron":
|
||||
cronCmd()
|
||||
case "skills":
|
||||
@@ -152,6 +155,7 @@ func printHelp() {
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||
fmt.Println(" agent Interact with the agent directly")
|
||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||
fmt.Println(" gateway Start picoclaw gateway")
|
||||
fmt.Println(" status Show picoclaw status")
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
@@ -682,6 +686,239 @@ func statusCmd() {
|
||||
} else {
|
||||
fmt.Println("vLLM/Local: not set")
|
||||
}
|
||||
|
||||
store, _ := auth.LoadStore()
|
||||
if store != nil && len(store.Credentials) > 0 {
|
||||
fmt.Println("\nOAuth/Token Auth:")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "authenticated"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
}
|
||||
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
authHelp()
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Args[2] {
|
||||
case "login":
|
||||
authLoginCmd()
|
||||
case "logout":
|
||||
authLogoutCmd()
|
||||
case "status":
|
||||
authStatusCmd()
|
||||
default:
|
||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
||||
authHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func authHelp() {
|
||||
fmt.Println("\nAuth commands:")
|
||||
fmt.Println(" login Login via OAuth or paste token")
|
||||
fmt.Println(" logout Remove stored credentials")
|
||||
fmt.Println(" status Show current auth status")
|
||||
fmt.Println()
|
||||
fmt.Println("Login options:")
|
||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic)")
|
||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" picoclaw auth login --provider openai")
|
||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||
fmt.Println(" picoclaw auth logout --provider openai")
|
||||
fmt.Println(" picoclaw auth status")
|
||||
}
|
||||
|
||||
func authLoginCmd() {
|
||||
provider := ""
|
||||
useDeviceCode := false
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--device-code":
|
||||
useDeviceCode = true
|
||||
}
|
||||
}
|
||||
|
||||
if provider == "" {
|
||||
fmt.Println("Error: --provider is required")
|
||||
fmt.Println("Supported providers: openai, anthropic")
|
||||
return
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "openai":
|
||||
authLoginOpenAI(useDeviceCode)
|
||||
case "anthropic":
|
||||
authLoginPasteToken(provider)
|
||||
default:
|
||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||
fmt.Println("Supported providers: openai, anthropic")
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginOpenAI(useDeviceCode bool) {
|
||||
cfg := auth.OpenAIOAuthConfig()
|
||||
|
||||
var cred *auth.AuthCredential
|
||||
var err error
|
||||
|
||||
if useDeviceCode {
|
||||
cred, err = auth.LoginDeviceCode(cfg)
|
||||
} else {
|
||||
cred, err = auth.LoginBrowser(cfg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := auth.SetCredential("openai", cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Login successful!")
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf("Account: %s\n", cred.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginPasteToken(provider string) {
|
||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Printf("Login failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := auth.SetCredential(provider, cred); err != nil {
|
||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = "token"
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||
}
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Token saved for %s!\n", provider)
|
||||
}
|
||||
|
||||
func authLogoutCmd() {
|
||||
provider := ""
|
||||
|
||||
args := os.Args[3:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--provider", "-p":
|
||||
if i+1 < len(args) {
|
||||
provider = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider != "" {
|
||||
if err := auth.DeleteCredential(provider); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
switch provider {
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
case "anthropic":
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
}
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Printf("Logged out from %s\n", provider)
|
||||
} else {
|
||||
if err := auth.DeleteAllCredentials(); err != nil {
|
||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appCfg, err := loadConfig()
|
||||
if err == nil {
|
||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||
config.SaveConfig(getConfigPath(), appCfg)
|
||||
}
|
||||
|
||||
fmt.Println("Logged out from all providers")
|
||||
}
|
||||
}
|
||||
|
||||
func authStatusCmd() {
|
||||
store, err := auth.LoadStore()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading auth store: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(store.Credentials) == 0 {
|
||||
fmt.Println("No authenticated providers.")
|
||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nAuthenticated Providers:")
|
||||
fmt.Println("------------------------")
|
||||
for provider, cred := range store.Credentials {
|
||||
status := "active"
|
||||
if cred.IsExpired() {
|
||||
status = "expired"
|
||||
} else if cred.NeedsRefresh() {
|
||||
status = "needs refresh"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s:\n", provider)
|
||||
fmt.Printf(" Method: %s\n", cred.AuthMethod)
|
||||
fmt.Printf(" Status: %s\n", status)
|
||||
if cred.AccountID != "" {
|
||||
fmt.Printf(" Account: %s\n", cred.AccountID)
|
||||
}
|
||||
if !cred.ExpiresAt.IsZero() {
|
||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user