mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-27 00:45:56 +00:00
v6 version first commit
This commit is contained in:
178
sdk/auth/claude.go
Normal file
178
sdk/auth/claude.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ClaudeAuthenticator implements the OAuth login flow for Anthropic Claude accounts.
|
||||
type ClaudeAuthenticator struct {
|
||||
CallbackPort int
|
||||
}
|
||||
|
||||
// NewClaudeAuthenticator constructs a Claude authenticator with default settings.
|
||||
func NewClaudeAuthenticator() *ClaudeAuthenticator {
|
||||
return &ClaudeAuthenticator{CallbackPort: 54545}
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) Provider() string {
|
||||
return "claude"
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) RefreshLead() *time.Duration {
|
||||
d := 4 * time.Hour
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
pkceCodes, err := claude.GeneratePKCECodes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude pkce generation failed: %w", err)
|
||||
}
|
||||
|
||||
state, err := misc.GenerateRandomState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude state generation failed: %w", err)
|
||||
}
|
||||
|
||||
oauthServer := claude.NewOAuthServer(a.CallbackPort)
|
||||
if err = oauthServer.Start(); err != nil {
|
||||
if strings.Contains(err.Error(), "already in use") {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrPortInUse, err)
|
||||
}
|
||||
return nil, claude.NewAuthenticationError(claude.ErrServerStartFailed, err)
|
||||
}
|
||||
defer func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if stopErr := oauthServer.Stop(stopCtx); stopErr != nil {
|
||||
log.Warnf("claude oauth server stop error: %v", stopErr)
|
||||
}
|
||||
}()
|
||||
|
||||
authSvc := claude.NewClaudeAuth(cfg)
|
||||
|
||||
authURL, returnedState, err := authSvc.GenerateAuthURL(state, pkceCodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude authorization url generation failed: %w", err)
|
||||
}
|
||||
state = returnedState
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Claude authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Claude authentication callback...")
|
||||
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, claude.NewOAuthError(result.Error, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if result.State != state {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("state mismatch"))
|
||||
}
|
||||
|
||||
log.Debug("Claude authorization code received; exchanging for tokens")
|
||||
|
||||
authBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, state, pkceCodes)
|
||||
if err != nil {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, err)
|
||||
}
|
||||
|
||||
tokenStorage := authSvc.CreateTokenStorage(authBundle)
|
||||
|
||||
if tokenStorage == nil || tokenStorage.Email == "" {
|
||||
return nil, fmt.Errorf("claude token storage missing account information")
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("claude-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Claude authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
log.Info("Claude API key obtained and stored")
|
||||
}
|
||||
|
||||
return &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error) {
|
||||
if record == nil || record.Storage == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: empty token record for claude refresh")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
storage, ok := record.Storage.(*claude.ClaudeTokenStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cliproxy auth: unexpected token storage type for claude refresh")
|
||||
}
|
||||
|
||||
// Refresh via auth service directly (no legacy client)
|
||||
svc := claude.NewClaudeAuth(cfg)
|
||||
td, err := svc.RefreshTokensWithRetry(ctx, storage.RefreshToken, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc.UpdateTokenStorage(storage, td)
|
||||
|
||||
result := &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: record.FileName,
|
||||
Storage: storage,
|
||||
Metadata: record.Metadata,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
176
sdk/auth/codex.go
Normal file
176
sdk/auth/codex.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// CodexAuthenticator implements the OAuth login flow for Codex accounts.
|
||||
type CodexAuthenticator struct {
|
||||
CallbackPort int
|
||||
}
|
||||
|
||||
// NewCodexAuthenticator constructs a Codex authenticator with default settings.
|
||||
func NewCodexAuthenticator() *CodexAuthenticator {
|
||||
return &CodexAuthenticator{CallbackPort: 1455}
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) Provider() string {
|
||||
return "codex"
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) RefreshLead() *time.Duration {
|
||||
d := 5 * 24 * time.Hour
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
pkceCodes, err := codex.GeneratePKCECodes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codex pkce generation failed: %w", err)
|
||||
}
|
||||
|
||||
state, err := misc.GenerateRandomState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codex state generation failed: %w", err)
|
||||
}
|
||||
|
||||
oauthServer := codex.NewOAuthServer(a.CallbackPort)
|
||||
if err = oauthServer.Start(); err != nil {
|
||||
if strings.Contains(err.Error(), "already in use") {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrPortInUse, err)
|
||||
}
|
||||
return nil, codex.NewAuthenticationError(codex.ErrServerStartFailed, err)
|
||||
}
|
||||
defer func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if stopErr := oauthServer.Stop(stopCtx); stopErr != nil {
|
||||
log.Warnf("codex oauth server stop error: %v", stopErr)
|
||||
}
|
||||
}()
|
||||
|
||||
authSvc := codex.NewCodexAuth(cfg)
|
||||
|
||||
authURL, err := authSvc.GenerateAuthURL(state, pkceCodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codex authorization url generation failed: %w", err)
|
||||
}
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Codex authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Codex authentication callback...")
|
||||
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, codex.NewOAuthError(result.Error, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if result.State != state {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("state mismatch"))
|
||||
}
|
||||
|
||||
log.Debug("Codex authorization code received; exchanging for tokens")
|
||||
|
||||
authBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, pkceCodes)
|
||||
if err != nil {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
|
||||
}
|
||||
|
||||
tokenStorage := authSvc.CreateTokenStorage(authBundle)
|
||||
|
||||
if tokenStorage == nil || tokenStorage.Email == "" {
|
||||
return nil, fmt.Errorf("codex token storage missing account information")
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("codex-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Codex authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
log.Info("Codex API key obtained and stored")
|
||||
}
|
||||
|
||||
return &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error) {
|
||||
if record == nil || record.Storage == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: empty token record for codex refresh")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
storage, ok := record.Storage.(*codex.CodexTokenStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cliproxy auth: unexpected token storage type for codex refresh")
|
||||
}
|
||||
|
||||
svc := codex.NewCodexAuth(cfg)
|
||||
td, err := svc.RefreshTokensWithRetry(ctx, storage.RefreshToken, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc.UpdateTokenStorage(storage, td)
|
||||
|
||||
result := &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: record.FileName,
|
||||
Storage: storage,
|
||||
Metadata: record.Metadata,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
40
sdk/auth/errors.go
Normal file
40
sdk/auth/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
)
|
||||
|
||||
// ProjectSelectionError indicates that the user must choose a specific project ID.
|
||||
type ProjectSelectionError struct {
|
||||
Email string
|
||||
Projects []interfaces.GCPProjectProjects
|
||||
}
|
||||
|
||||
func (e *ProjectSelectionError) Error() string {
|
||||
if e == nil {
|
||||
return "cliproxy auth: project selection required"
|
||||
}
|
||||
return fmt.Sprintf("cliproxy auth: project selection required for %s", e.Email)
|
||||
}
|
||||
|
||||
// ProjectsDisplay returns the projects list for caller presentation.
|
||||
func (e *ProjectSelectionError) ProjectsDisplay() []interfaces.GCPProjectProjects {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Projects
|
||||
}
|
||||
|
||||
// EmailRequiredError indicates that the calling context must provide an email or alias.
|
||||
type EmailRequiredError struct {
|
||||
Prompt string
|
||||
}
|
||||
|
||||
func (e *EmailRequiredError) Error() string {
|
||||
if e == nil || e.Prompt == "" {
|
||||
return "cliproxy auth: email is required"
|
||||
}
|
||||
return e.Prompt
|
||||
}
|
||||
37
sdk/auth/filestore.go
Normal file
37
sdk/auth/filestore.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
// FileTokenStore persists token records into the configured auth directory using the
|
||||
// filename suggested by the authenticator. Relative paths are resolved against cfg.AuthDir.
|
||||
type FileTokenStore struct{}
|
||||
|
||||
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
||||
// TokenStorage implementation embedded in the token record.
|
||||
func NewFileTokenStore() *FileTokenStore {
|
||||
return &FileTokenStore{}
|
||||
}
|
||||
|
||||
// Save writes the token storage to the resolved file path.
|
||||
func (s *FileTokenStore) Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error) {
|
||||
if record == nil || record.Storage == nil {
|
||||
return "", fmt.Errorf("cliproxy auth: token record is incomplete")
|
||||
}
|
||||
target := record.FileName
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("cliproxy auth: missing file name for provider %s", record.Provider)
|
||||
}
|
||||
if cfg != nil && !filepath.IsAbs(target) {
|
||||
target = filepath.Join(cfg.AuthDir, target)
|
||||
}
|
||||
if err := record.Storage.SaveTokenToFile(target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
36
sdk/auth/gemini-web.go
Normal file
36
sdk/auth/gemini-web.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
// GeminiWebAuthenticator provides a minimal wrapper so core components can treat
|
||||
// Gemini Web credentials via the shared Authenticator contract.
|
||||
type GeminiWebAuthenticator struct{}
|
||||
|
||||
func NewGeminiWebAuthenticator() *GeminiWebAuthenticator { return &GeminiWebAuthenticator{} }
|
||||
|
||||
func (a *GeminiWebAuthenticator) Provider() string { return "gemini-web" }
|
||||
|
||||
func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
_ = ctx
|
||||
_ = cfg
|
||||
_ = opts
|
||||
return nil, fmt.Errorf("gemini-web authenticator does not support scripted login; use CLI --gemini-web-auth")
|
||||
}
|
||||
|
||||
func (a *GeminiWebAuthenticator) Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error) {
|
||||
_ = ctx
|
||||
_ = cfg
|
||||
_ = record
|
||||
return nil, ErrRefreshNotSupported
|
||||
}
|
||||
|
||||
func (a *GeminiWebAuthenticator) RefreshLead() *time.Duration {
|
||||
d := 9 * time.Minute
|
||||
return &d
|
||||
}
|
||||
72
sdk/auth/gemini.go
Normal file
72
sdk/auth/gemini.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GeminiAuthenticator implements the login flow for Google Gemini CLI accounts.
|
||||
type GeminiAuthenticator struct{}
|
||||
|
||||
// NewGeminiAuthenticator constructs a Gemini authenticator.
|
||||
func NewGeminiAuthenticator() *GeminiAuthenticator {
|
||||
return &GeminiAuthenticator{}
|
||||
}
|
||||
|
||||
func (a *GeminiAuthenticator) Provider() string {
|
||||
return "gemini"
|
||||
}
|
||||
|
||||
func (a *GeminiAuthenticator) RefreshLead() *time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
var ts gemini.GeminiTokenStorage
|
||||
if opts.ProjectID != "" {
|
||||
ts.ProjectID = opts.ProjectID
|
||||
}
|
||||
|
||||
geminiAuth := gemini.NewGeminiAuth()
|
||||
_, err := geminiAuth.GetAuthenticatedClient(ctx, &ts, cfg, opts.NoBrowser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini authentication failed: %w", err)
|
||||
}
|
||||
|
||||
// Skip onboarding here; rely on upstream configuration
|
||||
|
||||
fileName := fmt.Sprintf("%s-%s.json", ts.Email, ts.ProjectID)
|
||||
metadata := map[string]string{
|
||||
"email": ts.Email,
|
||||
"project_id": ts.ProjectID,
|
||||
}
|
||||
|
||||
log.Info("Gemini authentication successful")
|
||||
|
||||
return &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: &ts,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GeminiAuthenticator) Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error) {
|
||||
return nil, ErrRefreshNotSupported
|
||||
}
|
||||
42
sdk/auth/interfaces.go
Normal file
42
sdk/auth/interfaces.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported")
|
||||
|
||||
// LoginOptions captures generic knobs shared across authenticators.
|
||||
// Provider-specific logic can inspect Metadata for extra parameters.
|
||||
type LoginOptions struct {
|
||||
NoBrowser bool
|
||||
ProjectID string
|
||||
Metadata map[string]string
|
||||
Prompt func(prompt string) (string, error)
|
||||
}
|
||||
|
||||
// TokenRecord represents credential material produced by an authenticator.
|
||||
type TokenRecord struct {
|
||||
Provider string
|
||||
FileName string
|
||||
Storage baseauth.TokenStorage
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// TokenStore persists token records.
|
||||
type TokenStore interface {
|
||||
Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error)
|
||||
}
|
||||
|
||||
// Authenticator manages login and optional refresh flows for a provider.
|
||||
type Authenticator interface {
|
||||
Provider() string
|
||||
Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error)
|
||||
Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error)
|
||||
RefreshLead() *time.Duration
|
||||
}
|
||||
95
sdk/auth/manager.go
Normal file
95
sdk/auth/manager.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
// Manager aggregates authenticators and coordinates persistence via a token store.
|
||||
type Manager struct {
|
||||
authenticators map[string]Authenticator
|
||||
store TokenStore
|
||||
}
|
||||
|
||||
// NewManager constructs a manager with the provided token store and authenticators.
|
||||
// If store is nil, the caller must set it later using SetStore.
|
||||
func NewManager(store TokenStore, authenticators ...Authenticator) *Manager {
|
||||
mgr := &Manager{
|
||||
authenticators: make(map[string]Authenticator),
|
||||
store: store,
|
||||
}
|
||||
for i := range authenticators {
|
||||
mgr.Register(authenticators[i])
|
||||
}
|
||||
return mgr
|
||||
}
|
||||
|
||||
// Register adds or replaces an authenticator keyed by its provider identifier.
|
||||
func (m *Manager) Register(a Authenticator) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if m.authenticators == nil {
|
||||
m.authenticators = make(map[string]Authenticator)
|
||||
}
|
||||
m.authenticators[a.Provider()] = a
|
||||
}
|
||||
|
||||
// SetStore updates the token store used for persistence.
|
||||
func (m *Manager) SetStore(store TokenStore) {
|
||||
m.store = store
|
||||
}
|
||||
|
||||
// Login executes the provider login flow and persists the resulting token record.
|
||||
func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config, opts *LoginOptions) (*TokenRecord, string, error) {
|
||||
auth, ok := m.authenticators[provider]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("cliproxy auth: authenticator %s not registered", provider)
|
||||
}
|
||||
|
||||
record, err := auth.Login(ctx, cfg, opts)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, "", fmt.Errorf("cliproxy auth: authenticator %s returned nil record", provider)
|
||||
}
|
||||
|
||||
if m.store == nil {
|
||||
return record, "", nil
|
||||
}
|
||||
|
||||
savedPath, err := m.store.Save(ctx, cfg, record)
|
||||
if err != nil {
|
||||
return record, "", err
|
||||
}
|
||||
return record, savedPath, nil
|
||||
}
|
||||
|
||||
// Refresh delegates to the provider-specific refresh implementation and persists the result.
|
||||
func (m *Manager) Refresh(ctx context.Context, provider string, cfg *config.Config, record *TokenRecord) (*TokenRecord, string, error) {
|
||||
auth, ok := m.authenticators[provider]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("cliproxy auth: authenticator %s not registered", provider)
|
||||
}
|
||||
|
||||
updated, err := auth.Refresh(ctx, cfg, record)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if updated == nil {
|
||||
updated = record
|
||||
}
|
||||
|
||||
if m.store == nil {
|
||||
return updated, "", nil
|
||||
}
|
||||
|
||||
savedPath, err := m.store.Save(ctx, cfg, updated)
|
||||
if err != nil {
|
||||
return updated, "", err
|
||||
}
|
||||
return updated, savedPath, nil
|
||||
}
|
||||
147
sdk/auth/qwen.go
Normal file
147
sdk/auth/qwen.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// QwenAuthenticator implements the device flow login for Qwen accounts.
|
||||
type QwenAuthenticator struct{}
|
||||
|
||||
// NewQwenAuthenticator constructs a Qwen authenticator.
|
||||
func NewQwenAuthenticator() *QwenAuthenticator {
|
||||
return &QwenAuthenticator{}
|
||||
}
|
||||
|
||||
func (a *QwenAuthenticator) Provider() string {
|
||||
return "qwen"
|
||||
}
|
||||
|
||||
func (a *QwenAuthenticator) RefreshLead() *time.Duration {
|
||||
d := 3 * time.Hour
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
authSvc := qwen.NewQwenAuth(cfg)
|
||||
|
||||
deviceFlow, err := authSvc.InitiateDeviceFlow(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qwen device flow initiation failed: %w", err)
|
||||
}
|
||||
|
||||
authURL := deviceFlow.VerificationURIComplete
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Qwen authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
} else {
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Qwen authentication...")
|
||||
|
||||
tokenData, err := authSvc.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qwen authentication failed: %w", err)
|
||||
}
|
||||
|
||||
tokenStorage := authSvc.CreateTokenStorage(tokenData)
|
||||
|
||||
email := ""
|
||||
if opts.Metadata != nil {
|
||||
email = opts.Metadata["email"]
|
||||
if email == "" {
|
||||
email = opts.Metadata["alias"]
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" && opts.Prompt != nil {
|
||||
email, err = opts.Prompt("Please input your email address or alias for Qwen:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return nil, &EmailRequiredError{Prompt: "Please provide an email address or alias for Qwen."}
|
||||
}
|
||||
|
||||
tokenStorage.Email = email
|
||||
|
||||
// no legacy client construction
|
||||
|
||||
fileName := fmt.Sprintf("qwen-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Qwen authentication successful")
|
||||
|
||||
return &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *QwenAuthenticator) Refresh(ctx context.Context, cfg *config.Config, record *TokenRecord) (*TokenRecord, error) {
|
||||
if record == nil || record.Storage == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: empty token record for qwen refresh")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
storage, ok := record.Storage.(*qwen.QwenTokenStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cliproxy auth: unexpected token storage type for qwen refresh")
|
||||
}
|
||||
|
||||
svc := qwen.NewQwenAuth(cfg)
|
||||
td, err := svc.RefreshTokens(ctx, storage.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage.AccessToken = td.AccessToken
|
||||
storage.RefreshToken = td.RefreshToken
|
||||
storage.ResourceURL = td.ResourceURL
|
||||
storage.Expire = td.Expire
|
||||
|
||||
result := &TokenRecord{
|
||||
Provider: a.Provider(),
|
||||
FileName: record.FileName,
|
||||
Storage: storage,
|
||||
Metadata: record.Metadata,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user