Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
274
backend/sdk/auth/antigravity.go
Normal file
274
backend/sdk/auth/antigravity.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AntigravityAuthenticator implements OAuth login for the antigravity provider.
|
||||
type AntigravityAuthenticator struct{}
|
||||
|
||||
// NewAntigravityAuthenticator constructs a new authenticator instance.
|
||||
func NewAntigravityAuthenticator() Authenticator { return &AntigravityAuthenticator{} }
|
||||
|
||||
// Provider returns the provider key for antigravity.
|
||||
func (AntigravityAuthenticator) Provider() string { return "antigravity" }
|
||||
|
||||
// RefreshLead instructs the manager to refresh five minutes before expiry.
|
||||
func (AntigravityAuthenticator) RefreshLead() *time.Duration {
|
||||
return new(5 * time.Minute)
|
||||
}
|
||||
|
||||
// Login launches a local OAuth flow to obtain antigravity tokens and persists them.
|
||||
func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
callbackPort := antigravity.CallbackPort
|
||||
if opts.CallbackPort > 0 {
|
||||
callbackPort = opts.CallbackPort
|
||||
}
|
||||
|
||||
authSvc := antigravity.NewAntigravityAuth(cfg, nil)
|
||||
|
||||
state, err := misc.GenerateRandomState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("antigravity: failed to generate state: %w", err)
|
||||
}
|
||||
|
||||
srv, port, cbChan, errServer := startAntigravityCallbackServer(callbackPort)
|
||||
if errServer != nil {
|
||||
return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer)
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", port)
|
||||
authURL := authSvc.BuildAuthURL(state, redirectURI)
|
||||
|
||||
if !opts.NoBrowser {
|
||||
fmt.Println("Opening browser for antigravity authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(port)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if errOpen := browser.OpenURL(authURL); errOpen != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", errOpen)
|
||||
util.PrintSSHTunnelInstructions(port)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(port)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for antigravity authentication callback...")
|
||||
|
||||
var cbRes callbackResult
|
||||
timeoutTimer := time.NewTimer(5 * time.Minute)
|
||||
defer timeoutTimer.Stop()
|
||||
|
||||
var manualPromptTimer *time.Timer
|
||||
var manualPromptC <-chan time.Time
|
||||
if opts.Prompt != nil {
|
||||
manualPromptTimer = time.NewTimer(15 * time.Second)
|
||||
manualPromptC = manualPromptTimer.C
|
||||
defer manualPromptTimer.Stop()
|
||||
}
|
||||
|
||||
var manualInputCh <-chan string
|
||||
var manualInputErrCh <-chan error
|
||||
|
||||
waitForCallback:
|
||||
for {
|
||||
select {
|
||||
case res := <-cbChan:
|
||||
cbRes = res
|
||||
break waitForCallback
|
||||
case <-manualPromptC:
|
||||
manualPromptC = nil
|
||||
if manualPromptTimer != nil {
|
||||
manualPromptTimer.Stop()
|
||||
}
|
||||
select {
|
||||
case res := <-cbChan:
|
||||
cbRes = res
|
||||
break waitForCallback
|
||||
default:
|
||||
}
|
||||
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the antigravity callback URL (or press Enter to keep waiting): ")
|
||||
continue
|
||||
case input := <-manualInputCh:
|
||||
manualInputCh = nil
|
||||
manualInputErrCh = nil
|
||||
parsed, errParse := misc.ParseOAuthCallback(input)
|
||||
if errParse != nil {
|
||||
return nil, errParse
|
||||
}
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
cbRes = callbackResult{
|
||||
Code: parsed.Code,
|
||||
State: parsed.State,
|
||||
Error: parsed.Error,
|
||||
}
|
||||
break waitForCallback
|
||||
case errManual := <-manualInputErrCh:
|
||||
return nil, errManual
|
||||
case <-timeoutTimer.C:
|
||||
return nil, fmt.Errorf("antigravity: authentication timed out")
|
||||
}
|
||||
}
|
||||
|
||||
if cbRes.Error != "" {
|
||||
return nil, fmt.Errorf("antigravity: authentication failed: %s", cbRes.Error)
|
||||
}
|
||||
if cbRes.State != state {
|
||||
return nil, fmt.Errorf("antigravity: invalid state")
|
||||
}
|
||||
if cbRes.Code == "" {
|
||||
return nil, fmt.Errorf("antigravity: missing authorization code")
|
||||
}
|
||||
|
||||
tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, cbRes.Code, redirectURI)
|
||||
if errToken != nil {
|
||||
return nil, fmt.Errorf("antigravity: token exchange failed: %w", errToken)
|
||||
}
|
||||
|
||||
accessToken := strings.TrimSpace(tokenResp.AccessToken)
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("antigravity: token exchange returned empty access token")
|
||||
}
|
||||
|
||||
email, errInfo := authSvc.FetchUserInfo(ctx, accessToken)
|
||||
if errInfo != nil {
|
||||
return nil, fmt.Errorf("antigravity: fetch user info failed: %w", errInfo)
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("antigravity: empty email returned from user info")
|
||||
}
|
||||
|
||||
// Fetch project ID via loadCodeAssist.
|
||||
projectID := ""
|
||||
if accessToken != "" {
|
||||
fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken)
|
||||
if errProject != nil {
|
||||
return nil, fmt.Errorf("antigravity: failed to fetch project ID: %w", errProject)
|
||||
} else {
|
||||
projectID = fetchedProjectID
|
||||
log.Infof("antigravity: obtained project ID %s", util.HideAPIKey(projectID))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, fmt.Errorf("antigravity: project ID discovery returned empty project")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
metadata := map[string]any{
|
||||
"type": "antigravity",
|
||||
"access_token": tokenResp.AccessToken,
|
||||
"refresh_token": tokenResp.RefreshToken,
|
||||
"expires_in": tokenResp.ExpiresIn,
|
||||
"timestamp": now.UnixMilli(),
|
||||
"expired": now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
if email != "" {
|
||||
metadata["email"] = email
|
||||
}
|
||||
if projectID != "" {
|
||||
metadata["project_id"] = projectID
|
||||
}
|
||||
|
||||
fileName := antigravity.CredentialFileName(email)
|
||||
label := email
|
||||
if label == "" {
|
||||
label = "antigravity"
|
||||
}
|
||||
|
||||
fmt.Println("Antigravity authentication successful")
|
||||
if projectID != "" {
|
||||
fmt.Printf("Using GCP project: %s\n", util.HideAPIKey(projectID))
|
||||
}
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "antigravity",
|
||||
FileName: fileName,
|
||||
Label: label,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
Code string
|
||||
Error string
|
||||
State string
|
||||
}
|
||||
|
||||
func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) {
|
||||
if port <= 0 {
|
||||
port = antigravity.CallbackPort
|
||||
}
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
port = listener.Addr().(*net.TCPAddr).Port
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/oauth-callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
res := callbackResult{
|
||||
Code: strings.TrimSpace(q.Get("code")),
|
||||
Error: strings.TrimSpace(q.Get("error")),
|
||||
State: strings.TrimSpace(q.Get("state")),
|
||||
}
|
||||
resultCh <- res
|
||||
if res.Code != "" && res.Error == "" {
|
||||
_, _ = w.Write([]byte("<h1>Login successful</h1><p>You can close this window.</p>"))
|
||||
} else {
|
||||
_, _ = w.Write([]byte("<h1>Login failed</h1><p>Please check the CLI output.</p>"))
|
||||
}
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() {
|
||||
if errServe := srv.Serve(listener); errServe != nil && !strings.Contains(errServe.Error(), "Server closed") {
|
||||
log.Warnf("antigravity callback server error: %v", errServe)
|
||||
}
|
||||
}()
|
||||
|
||||
return srv, port, resultCh, nil
|
||||
}
|
||||
|
||||
// FetchAntigravityProjectID exposes project discovery for external callers.
|
||||
func FetchAntigravityProjectID(ctx context.Context, accessToken string, httpClient *http.Client) (string, error) {
|
||||
cfg := &config.Config{}
|
||||
authSvc := antigravity.NewAntigravityAuth(cfg, httpClient)
|
||||
return authSvc.FetchProjectID(ctx, accessToken)
|
||||
}
|
||||
232
backend/sdk/auth/claude.go
Normal file
232
backend/sdk/auth/claude.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
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 {
|
||||
return new(4 * time.Hour)
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
callbackPort := a.CallbackPort
|
||||
if opts.CallbackPort > 0 {
|
||||
callbackPort = opts.CallbackPort
|
||||
}
|
||||
|
||||
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(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 {
|
||||
fmt.Println("Opening browser for Claude authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for Claude authentication callback...")
|
||||
|
||||
callbackCh := make(chan *claude.OAuthResult, 1)
|
||||
callbackErrCh := make(chan error, 1)
|
||||
manualDescription := ""
|
||||
|
||||
go func() {
|
||||
result, errWait := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if errWait != nil {
|
||||
callbackErrCh <- errWait
|
||||
return
|
||||
}
|
||||
callbackCh <- result
|
||||
}()
|
||||
|
||||
var result *claude.OAuthResult
|
||||
var manualPromptTimer *time.Timer
|
||||
var manualPromptC <-chan time.Time
|
||||
if opts.Prompt != nil {
|
||||
manualPromptTimer = time.NewTimer(15 * time.Second)
|
||||
manualPromptC = manualPromptTimer.C
|
||||
defer manualPromptTimer.Stop()
|
||||
}
|
||||
|
||||
var manualInputCh <-chan string
|
||||
var manualInputErrCh <-chan error
|
||||
|
||||
waitForCallback:
|
||||
for {
|
||||
select {
|
||||
case result = <-callbackCh:
|
||||
break waitForCallback
|
||||
case err = <-callbackErrCh:
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
case <-manualPromptC:
|
||||
manualPromptC = nil
|
||||
if manualPromptTimer != nil {
|
||||
manualPromptTimer.Stop()
|
||||
}
|
||||
select {
|
||||
case result = <-callbackCh:
|
||||
break waitForCallback
|
||||
case err = <-callbackErrCh:
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, claude.NewAuthenticationError(claude.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
default:
|
||||
}
|
||||
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the Claude callback URL (or press Enter to keep waiting): ")
|
||||
continue
|
||||
case input := <-manualInputCh:
|
||||
manualInputCh = nil
|
||||
manualInputErrCh = nil
|
||||
parsed, errParse := misc.ParseOAuthCallback(input)
|
||||
if errParse != nil {
|
||||
return nil, errParse
|
||||
}
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
manualDescription = parsed.ErrorDescription
|
||||
result = &claude.OAuthResult{
|
||||
Code: parsed.Code,
|
||||
State: parsed.State,
|
||||
Error: parsed.Error,
|
||||
}
|
||||
break waitForCallback
|
||||
case errManual := <-manualInputErrCh:
|
||||
return nil, errManual
|
||||
}
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, claude.NewOAuthError(result.Error, manualDescription, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if result.State != state {
|
||||
log.Errorf("State mismatch: expected %s, got %s", state, result.State)
|
||||
return nil, claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("state mismatch"))
|
||||
}
|
||||
|
||||
log.Debug("Claude authorization code received; exchanging for tokens")
|
||||
log.Debugf("Code: %s, State: %s", result.Code[:min(20, len(result.Code))], state)
|
||||
|
||||
authBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, state, pkceCodes)
|
||||
if err != nil {
|
||||
log.Errorf("Token exchange failed: %v", err)
|
||||
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]any{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
if tokenStorage.AccountUUID != "" {
|
||||
metadata["account_uuid"] = tokenStorage.AccountUUID
|
||||
}
|
||||
if tokenStorage.OrganizationUUID != "" {
|
||||
metadata["organization_uuid"] = tokenStorage.OrganizationUUID
|
||||
}
|
||||
if tokenStorage.OrganizationName != "" {
|
||||
metadata["organization_name"] = tokenStorage.OrganizationName
|
||||
}
|
||||
if len(tokenStorage.DeviceIDs) > 0 {
|
||||
metadata[claude.ClaudeDeviceIDsMetadataKey] = append([]string(nil), tokenStorage.DeviceIDs...)
|
||||
}
|
||||
|
||||
fmt.Println("Claude authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
fmt.Println("Claude API key obtained and stored")
|
||||
}
|
||||
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
198
backend/sdk/auth/codex.go
Normal file
198
backend/sdk/auth/codex.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
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 {
|
||||
return new(5 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
if shouldUseCodexDeviceFlow(opts) {
|
||||
return a.loginWithDeviceFlow(ctx, cfg, opts)
|
||||
}
|
||||
|
||||
callbackPort := a.CallbackPort
|
||||
if opts.CallbackPort > 0 {
|
||||
callbackPort = opts.CallbackPort
|
||||
}
|
||||
|
||||
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(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 {
|
||||
fmt.Println("Opening browser for Codex authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(callbackPort)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for Codex authentication callback...")
|
||||
|
||||
callbackCh := make(chan *codex.OAuthResult, 1)
|
||||
callbackErrCh := make(chan error, 1)
|
||||
manualDescription := ""
|
||||
|
||||
go func() {
|
||||
result, errWait := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if errWait != nil {
|
||||
callbackErrCh <- errWait
|
||||
return
|
||||
}
|
||||
callbackCh <- result
|
||||
}()
|
||||
|
||||
var result *codex.OAuthResult
|
||||
var manualPromptTimer *time.Timer
|
||||
var manualPromptC <-chan time.Time
|
||||
if opts.Prompt != nil {
|
||||
manualPromptTimer = time.NewTimer(15 * time.Second)
|
||||
manualPromptC = manualPromptTimer.C
|
||||
defer manualPromptTimer.Stop()
|
||||
}
|
||||
|
||||
var manualInputCh <-chan string
|
||||
var manualInputErrCh <-chan error
|
||||
|
||||
waitForCallback:
|
||||
for {
|
||||
select {
|
||||
case result = <-callbackCh:
|
||||
break waitForCallback
|
||||
case err = <-callbackErrCh:
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
case <-manualPromptC:
|
||||
manualPromptC = nil
|
||||
if manualPromptTimer != nil {
|
||||
manualPromptTimer.Stop()
|
||||
}
|
||||
select {
|
||||
case result = <-callbackCh:
|
||||
break waitForCallback
|
||||
case err = <-callbackErrCh:
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)
|
||||
}
|
||||
return nil, err
|
||||
default:
|
||||
}
|
||||
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the Codex callback URL (or press Enter to keep waiting): ")
|
||||
continue
|
||||
case input := <-manualInputCh:
|
||||
manualInputCh = nil
|
||||
manualInputErrCh = nil
|
||||
parsed, errParse := misc.ParseOAuthCallback(input)
|
||||
if errParse != nil {
|
||||
return nil, errParse
|
||||
}
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
manualDescription = parsed.ErrorDescription
|
||||
result = &codex.OAuthResult{
|
||||
Code: parsed.Code,
|
||||
State: parsed.State,
|
||||
Error: parsed.Error,
|
||||
}
|
||||
break waitForCallback
|
||||
case errManual := <-manualInputErrCh:
|
||||
return nil, errManual
|
||||
}
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, codex.NewOAuthError(result.Error, manualDescription, 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)
|
||||
}
|
||||
|
||||
return a.buildAuthRecord(authSvc, authBundle)
|
||||
}
|
||||
294
backend/sdk/auth/codex_device.go
Normal file
294
backend/sdk/auth/codex_device.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
codexLoginModeMetadataKey = "codex_login_mode"
|
||||
codexLoginModeDevice = "device"
|
||||
codexDeviceUserCodeURL = "https://auth.openai.com/api/accounts/deviceauth/usercode"
|
||||
codexDeviceTokenURL = "https://auth.openai.com/api/accounts/deviceauth/token"
|
||||
codexDeviceVerificationURL = "https://auth.openai.com/codex/device"
|
||||
codexDeviceTokenExchangeRedirectURI = "https://auth.openai.com/deviceauth/callback"
|
||||
codexDeviceTimeout = 15 * time.Minute
|
||||
codexDeviceDefaultPollIntervalSeconds = 5
|
||||
)
|
||||
|
||||
type codexDeviceUserCodeRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
type codexDeviceUserCodeResponse struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
UserCodeAlt string `json:"usercode"`
|
||||
Interval json.RawMessage `json:"interval"`
|
||||
}
|
||||
|
||||
type codexDeviceTokenRequest struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
}
|
||||
|
||||
type codexDeviceTokenResponse struct {
|
||||
AuthorizationCode string `json:"authorization_code"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
func shouldUseCodexDeviceFlow(opts *LoginOptions) bool {
|
||||
if opts == nil || opts.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(opts.Metadata[codexLoginModeMetadataKey]), codexLoginModeDevice)
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) loginWithDeviceFlow(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{})
|
||||
|
||||
userCodeResp, err := requestCodexDeviceUserCode(ctx, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceCode := strings.TrimSpace(userCodeResp.UserCode)
|
||||
if deviceCode == "" {
|
||||
deviceCode = strings.TrimSpace(userCodeResp.UserCodeAlt)
|
||||
}
|
||||
deviceAuthID := strings.TrimSpace(userCodeResp.DeviceAuthID)
|
||||
if deviceCode == "" || deviceAuthID == "" {
|
||||
return nil, fmt.Errorf("codex device flow did not return required fields")
|
||||
}
|
||||
|
||||
pollInterval := parseCodexDevicePollInterval(userCodeResp.Interval)
|
||||
|
||||
fmt.Println("Starting Codex device authentication...")
|
||||
fmt.Printf("Codex device URL: %s\n", codexDeviceVerificationURL)
|
||||
fmt.Printf("Codex device code: %s\n", deviceCode)
|
||||
|
||||
if !opts.NoBrowser {
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the device URL manually")
|
||||
} else if errOpen := browser.OpenURL(codexDeviceVerificationURL); errOpen != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", errOpen)
|
||||
}
|
||||
}
|
||||
|
||||
tokenResp, err := pollCodexDeviceToken(ctx, httpClient, deviceAuthID, deviceCode, pollInterval)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authCode := strings.TrimSpace(tokenResp.AuthorizationCode)
|
||||
codeVerifier := strings.TrimSpace(tokenResp.CodeVerifier)
|
||||
codeChallenge := strings.TrimSpace(tokenResp.CodeChallenge)
|
||||
if authCode == "" || codeVerifier == "" || codeChallenge == "" {
|
||||
return nil, fmt.Errorf("codex device flow token response missing required fields")
|
||||
}
|
||||
|
||||
authSvc := codex.NewCodexAuth(cfg)
|
||||
authBundle, err := authSvc.ExchangeCodeForTokensWithRedirect(
|
||||
ctx,
|
||||
authCode,
|
||||
codexDeviceTokenExchangeRedirectURI,
|
||||
&codex.PKCECodes{
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
|
||||
}
|
||||
|
||||
return a.buildAuthRecord(authSvc, authBundle)
|
||||
}
|
||||
|
||||
func requestCodexDeviceUserCode(ctx context.Context, client *http.Client) (*codexDeviceUserCodeResponse, error) {
|
||||
body, err := json.Marshal(codexDeviceUserCodeRequest{ClientID: codex.ClientID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode codex device request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceUserCodeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create codex device request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request codex device code: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read codex device code response: %w", err)
|
||||
}
|
||||
|
||||
if !codexDeviceIsSuccessStatus(resp.StatusCode) {
|
||||
trimmed := strings.TrimSpace(string(respBody))
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("codex device endpoint is unavailable (status %d)", resp.StatusCode)
|
||||
}
|
||||
if trimmed == "" {
|
||||
trimmed = "empty response body"
|
||||
}
|
||||
return nil, fmt.Errorf("codex device code request failed with status %d: %s", resp.StatusCode, trimmed)
|
||||
}
|
||||
|
||||
var parsed codexDeviceUserCodeResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode codex device code response: %w", err)
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func pollCodexDeviceToken(ctx context.Context, client *http.Client, deviceAuthID, userCode string, interval time.Duration) (*codexDeviceTokenResponse, error) {
|
||||
deadline := time.Now().Add(codexDeviceTimeout)
|
||||
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("codex device authentication timed out after 15 minutes")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(codexDeviceTokenRequest{
|
||||
DeviceAuthID: deviceAuthID,
|
||||
UserCode: userCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode codex device poll request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceTokenURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create codex device poll request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to poll codex device token: %w", err)
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read codex device poll response: %w", readErr)
|
||||
}
|
||||
|
||||
switch {
|
||||
case codexDeviceIsSuccessStatus(resp.StatusCode):
|
||||
var parsed codexDeviceTokenResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode codex device token response: %w", err)
|
||||
}
|
||||
return &parsed, nil
|
||||
case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
continue
|
||||
}
|
||||
default:
|
||||
trimmed := strings.TrimSpace(string(respBody))
|
||||
if trimmed == "" {
|
||||
trimmed = "empty response body"
|
||||
}
|
||||
return nil, fmt.Errorf("codex device token polling failed with status %d: %s", resp.StatusCode, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCodexDevicePollInterval(raw json.RawMessage) time.Duration {
|
||||
defaultInterval := time.Duration(codexDeviceDefaultPollIntervalSeconds) * time.Second
|
||||
if len(raw) == 0 {
|
||||
return defaultInterval
|
||||
}
|
||||
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
if seconds, convErr := strconv.Atoi(strings.TrimSpace(asString)); convErr == nil && seconds > 0 {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
var asInt int
|
||||
if err := json.Unmarshal(raw, &asInt); err == nil && asInt > 0 {
|
||||
return time.Duration(asInt) * time.Second
|
||||
}
|
||||
|
||||
return defaultInterval
|
||||
}
|
||||
|
||||
func codexDeviceIsSuccessStatus(code int) bool {
|
||||
return code >= 200 && code < 300
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundle *codex.CodexAuthBundle) (*coreauth.Auth, error) {
|
||||
tokenStorage := authSvc.CreateTokenStorage(authBundle)
|
||||
|
||||
if tokenStorage == nil || tokenStorage.Email == "" {
|
||||
return nil, fmt.Errorf("codex token storage missing account information")
|
||||
}
|
||||
|
||||
planType := ""
|
||||
hashAccountID := ""
|
||||
if tokenStorage.IDToken != "" {
|
||||
if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil {
|
||||
planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType)
|
||||
accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID)
|
||||
if accountID != "" {
|
||||
digest := sha256.Sum256([]byte(accountID))
|
||||
hashAccountID = hex.EncodeToString(digest[:])[:8]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
|
||||
metadata := map[string]any{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
fmt.Println("Codex authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
fmt.Println("Codex API key obtained and stored")
|
||||
}
|
||||
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
Attributes: map[string]string{
|
||||
"plan_type": planType,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
13
backend/sdk/auth/errors.go
Normal file
13
backend/sdk/auth/errors.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package auth
|
||||
|
||||
// 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
|
||||
}
|
||||
540
backend/sdk/auth/filestore.go
Normal file
540
backend/sdk/auth/filestore.go
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
// PluginAuthParser parses auth JSON owned by plugin providers.
|
||||
type PluginAuthParser interface {
|
||||
ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error)
|
||||
}
|
||||
|
||||
// PluginMultiAuthParser expands one auth JSON payload into multiple plugin auth records.
|
||||
// Returning handled=true with an empty slice means the plugin intentionally suppresses built-in parsing.
|
||||
type PluginMultiAuthParser interface {
|
||||
ParseAuths(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error)
|
||||
}
|
||||
|
||||
type pluginAuthParserHolder struct {
|
||||
parser PluginAuthParser
|
||||
}
|
||||
|
||||
var pluginAuthParserValue atomic.Value
|
||||
|
||||
// RegisterPluginAuthParser registers the current plugin auth parser.
|
||||
func RegisterPluginAuthParser(parser PluginAuthParser) {
|
||||
pluginAuthParserValue.Store(pluginAuthParserHolder{parser: parser})
|
||||
}
|
||||
|
||||
func currentPluginAuthParser() PluginAuthParser {
|
||||
value := pluginAuthParserValue.Load()
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
holder, ok := value.(pluginAuthParserHolder)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return holder.parser
|
||||
}
|
||||
|
||||
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
|
||||
type FileTokenStore struct {
|
||||
mu sync.Mutex
|
||||
dirLock sync.RWMutex
|
||||
baseDir string
|
||||
}
|
||||
|
||||
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
||||
// TokenStorage implementation embedded in the token record.
|
||||
func NewFileTokenStore() *FileTokenStore {
|
||||
return &FileTokenStore{}
|
||||
}
|
||||
|
||||
// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
|
||||
func (s *FileTokenStore) SetBaseDir(dir string) {
|
||||
s.dirLock.Lock()
|
||||
s.baseDir = strings.TrimSpace(dir)
|
||||
s.dirLock.Unlock()
|
||||
}
|
||||
|
||||
// Save persists token storage and metadata to the resolved auth file path.
|
||||
func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("auth filestore: auth is nil")
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {
|
||||
return "", fmt.Errorf("auth filestore: %w", errWeight)
|
||||
}
|
||||
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
|
||||
}
|
||||
|
||||
// metadataSetter is a private interface for TokenStorage implementations that support metadata injection.
|
||||
type metadataSetter interface {
|
||||
SetMetadata(map[string]any)
|
||||
}
|
||||
|
||||
switch {
|
||||
case auth.Storage != nil:
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["disabled"] = auth.Disabled
|
||||
if setter, ok := auth.Storage.(metadataSetter); ok {
|
||||
setter.SetMetadata(auth.Metadata)
|
||||
}
|
||||
if err = auth.Storage.SaveTokenToFile(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case auth.Metadata != nil:
|
||||
auth.Metadata["disabled"] = auth.Disabled
|
||||
raw, errMarshal := json.Marshal(auth.Metadata)
|
||||
if errMarshal != nil {
|
||||
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
break
|
||||
}
|
||||
file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if errOpen != nil {
|
||||
return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen)
|
||||
}
|
||||
if _, errWrite := file.Write(raw); errWrite != nil {
|
||||
_ = file.Close()
|
||||
return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite)
|
||||
}
|
||||
if errClose := file.Close(); errClose != nil {
|
||||
return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose)
|
||||
}
|
||||
break
|
||||
} else if !os.IsNotExist(errRead) {
|
||||
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
||||
}
|
||||
if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
|
||||
return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes[cliproxyauth.AttributePath] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSource] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceFile
|
||||
|
||||
if strings.TrimSpace(auth.FileName) == "" {
|
||||
auth.FileName = auth.ID
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates all auth JSON files under the configured directory.
|
||||
func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
entries := make([]*cliproxyauth.Auth, 0)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
|
||||
return nil
|
||||
}
|
||||
auths, errReadAuths := s.readAuthFiles(path, dir)
|
||||
if errReadAuths != nil {
|
||||
return nil
|
||||
}
|
||||
if len(auths) > 0 {
|
||||
entries = append(entries, auths...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Delete removes the auth file.
|
||||
func (s *FileTokenStore) Delete(ctx context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("auth filestore: id is empty")
|
||||
}
|
||||
path, err := s.resolveDeletePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("auth filestore: delete failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) resolveDeletePath(id string) (string, error) {
|
||||
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
|
||||
return id, nil
|
||||
}
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
return filepath.Join(dir, id), nil
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Auth, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metadata := make(map[string]any)
|
||||
if err = json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal auth json: %w", err)
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(metadata)
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil {
|
||||
return nil, errWeight
|
||||
}
|
||||
provider, _ := metadata["type"].(string)
|
||||
provider = strings.TrimSpace(provider)
|
||||
if strings.EqualFold(provider, "gemini") {
|
||||
return nil, nil
|
||||
}
|
||||
info, errStat := os.Stat(path)
|
||||
if errStat != nil {
|
||||
return nil, fmt.Errorf("stat file: %w", errStat)
|
||||
}
|
||||
if parser := currentPluginAuthParser(); parser != nil {
|
||||
auths, handled, errParse := parsePluginAuthFile(parser, pluginapi.AuthParseRequest{
|
||||
Provider: provider,
|
||||
Path: path,
|
||||
FileName: s.idFor(path, baseDir),
|
||||
RawJSON: data,
|
||||
})
|
||||
if errParse == nil && handled {
|
||||
auths = compactPluginAuths(auths)
|
||||
if len(auths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
disabled, _ := metadata["disabled"].(bool)
|
||||
for index, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)
|
||||
if len(auths) > 1 {
|
||||
cliproxyauth.MarkPluginVirtualAuth(auth, path, index)
|
||||
}
|
||||
auth.CreatedAt = info.ModTime()
|
||||
auth.UpdatedAt = info.ModTime()
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes[cliproxyauth.AttributePath] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSource] = path
|
||||
auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceFile
|
||||
if disabled {
|
||||
auth.Disabled = true
|
||||
auth.Status = cliproxyauth.StatusDisabled
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["disabled"] = true
|
||||
}
|
||||
if errWeight := cliproxyauth.ApplyAuthWeightMetadata(auth, metadata); errWeight != nil {
|
||||
return nil, errWeight
|
||||
}
|
||||
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
}
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
if provider == "antigravity" {
|
||||
projectID := ""
|
||||
if pid, ok := metadata["project_id"].(string); ok {
|
||||
projectID = strings.TrimSpace(pid)
|
||||
}
|
||||
if projectID == "" {
|
||||
accessToken := extractAccessToken(metadata)
|
||||
if accessToken != "" {
|
||||
fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient)
|
||||
if errFetch == nil && strings.TrimSpace(fetchedProjectID) != "" {
|
||||
metadata["project_id"] = strings.TrimSpace(fetchedProjectID)
|
||||
if raw, errMarshal := json.Marshal(metadata); errMarshal == nil {
|
||||
if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil {
|
||||
_, _ = file.Write(raw)
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info, errStat = os.Stat(path)
|
||||
if errStat != nil {
|
||||
return nil, fmt.Errorf("stat file: %w", errStat)
|
||||
}
|
||||
id := s.idFor(path, baseDir)
|
||||
disabled, _ := metadata["disabled"].(bool)
|
||||
status := cliproxyauth.StatusActive
|
||||
if disabled {
|
||||
status = cliproxyauth.StatusDisabled
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
FileName: id,
|
||||
Label: s.labelFor(metadata),
|
||||
Status: status,
|
||||
Disabled: disabled,
|
||||
Attributes: map[string]string{
|
||||
cliproxyauth.AttributePath: path,
|
||||
cliproxyauth.AttributeSource: path,
|
||||
cliproxyauth.AttributeSourceBackend: cliproxyauth.AuthSourceFile,
|
||||
},
|
||||
Metadata: metadata,
|
||||
CreatedAt: info.ModTime(),
|
||||
UpdatedAt: info.ModTime(),
|
||||
LastRefreshedAt: time.Time{},
|
||||
NextRefreshAfter: time.Time{},
|
||||
}
|
||||
if email, ok := metadata["email"].(string); ok && email != "" {
|
||||
auth.Attributes["email"] = email
|
||||
}
|
||||
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
|
||||
return []*cliproxyauth.Auth{auth}, nil
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
|
||||
auths, errReadAuths := s.readAuthFiles(path, baseDir)
|
||||
if errReadAuths != nil || len(auths) == 0 {
|
||||
return nil, errReadAuths
|
||||
}
|
||||
return auths[0], nil
|
||||
}
|
||||
|
||||
func parsePluginAuthFile(parser PluginAuthParser, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
if parser == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if multiParser, ok := parser.(PluginMultiAuthParser); ok {
|
||||
return multiParser.ParseAuths(context.Background(), req)
|
||||
}
|
||||
auth, handled, errParse := parser.ParseAuth(context.Background(), req)
|
||||
if errParse != nil || !handled || auth == nil {
|
||||
return nil, handled, errParse
|
||||
}
|
||||
return []*cliproxyauth.Auth{auth}, true, nil
|
||||
}
|
||||
|
||||
func compactPluginAuths(auths []*cliproxyauth.Auth) []*cliproxyauth.Auth {
|
||||
if len(auths) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := auths[:0]
|
||||
for _, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, auth)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) idFor(path, baseDir string) string {
|
||||
id := path
|
||||
if baseDir != "" {
|
||||
if rel, errRel := filepath.Rel(baseDir, path); errRel == nil && rel != "" {
|
||||
id = rel
|
||||
}
|
||||
}
|
||||
// On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths.
|
||||
if runtime.GOOS == "windows" {
|
||||
id = strings.ToLower(id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("auth filestore: auth is nil")
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
|
||||
if filepath.IsAbs(fileName) {
|
||||
return fileName, nil
|
||||
}
|
||||
if dir := s.baseDirSnapshot(); dir != "" {
|
||||
return filepath.Join(dir, fileName), nil
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
if auth.ID == "" {
|
||||
return "", fmt.Errorf("auth filestore: missing id")
|
||||
}
|
||||
if filepath.IsAbs(auth.ID) {
|
||||
return auth.ID, nil
|
||||
}
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
return filepath.Join(dir, auth.ID), nil
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) labelFor(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := metadata["label"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if v, ok := metadata["email"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if project, ok := metadata["project_id"].(string); ok && project != "" {
|
||||
return project
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) baseDirSnapshot() string {
|
||||
s.dirLock.RLock()
|
||||
defer s.dirLock.RUnlock()
|
||||
return s.baseDir
|
||||
}
|
||||
|
||||
func extractAccessToken(metadata map[string]any) string {
|
||||
if at, ok := metadata["access_token"].(string); ok {
|
||||
if v := strings.TrimSpace(at); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if tokenMap, ok := metadata["token"].(map[string]any); ok {
|
||||
if at, ok := tokenMap["access_token"].(string); ok {
|
||||
if v := strings.TrimSpace(at); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing.
|
||||
func jsonEqual(a, b []byte) bool {
|
||||
var objA any
|
||||
var objB any
|
||||
if err := json.Unmarshal(a, &objA); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(b, &objB); err != nil {
|
||||
return false
|
||||
}
|
||||
return deepEqualJSON(objA, objB)
|
||||
}
|
||||
|
||||
func deepEqualJSON(a, b any) bool {
|
||||
switch valA := a.(type) {
|
||||
case map[string]any:
|
||||
valB, ok := b.(map[string]any)
|
||||
if !ok || len(valA) != len(valB) {
|
||||
return false
|
||||
}
|
||||
for key, subA := range valA {
|
||||
subB, ok1 := valB[key]
|
||||
if !ok1 || !deepEqualJSON(subA, subB) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
sliceB, ok := b.([]any)
|
||||
if !ok || len(valA) != len(sliceB) {
|
||||
return false
|
||||
}
|
||||
for i := range valA {
|
||||
if !deepEqualJSON(valA[i], sliceB[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case float64:
|
||||
valB, ok := b.(float64)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case string:
|
||||
valB, ok := b.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case bool:
|
||||
valB, ok := b.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case nil:
|
||||
return b == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
64
backend/sdk/auth/filestore_disabled_test.go
Normal file
64
backend/sdk/auth/filestore_disabled_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
type testTokenStorage struct {
|
||||
meta map[string]any
|
||||
}
|
||||
|
||||
func (s *testTokenStorage) SetMetadata(meta map[string]any) { s.meta = meta }
|
||||
|
||||
func (s *testTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
raw, err := json.Marshal(s.meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(authFilePath, raw, 0o600)
|
||||
}
|
||||
|
||||
func TestFileTokenStore_Save_DisabledPersistsFlagForTokenStorage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "disabled.json")
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"type":"test","disabled":true}`), 0o600); err != nil {
|
||||
t.Fatalf("seed auth file: %v", err)
|
||||
}
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
storage := &testTokenStorage{}
|
||||
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "disabled.json",
|
||||
Provider: "test",
|
||||
FileName: "disabled.json",
|
||||
Disabled: true,
|
||||
Storage: storage,
|
||||
Metadata: map[string]any{"type": "test"},
|
||||
}
|
||||
|
||||
if _, err := store.Save(ctx, auth); err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read auth file: %v", err)
|
||||
}
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
t.Fatalf("unmarshal auth file: %v", err)
|
||||
}
|
||||
if disabled, _ := meta["disabled"].(bool); !disabled {
|
||||
t.Fatalf("disabled=%v, want true (raw=%s)", meta["disabled"], string(raw))
|
||||
}
|
||||
}
|
||||
403
backend/sdk/auth/filestore_test.go
Normal file
403
backend/sdk/auth/filestore_test.go
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
||||
)
|
||||
|
||||
func TestExtractAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata map[string]any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"antigravity top-level access_token",
|
||||
map[string]any{"access_token": "tok-abc"},
|
||||
"tok-abc",
|
||||
},
|
||||
{
|
||||
"gemini nested token.access_token",
|
||||
map[string]any{
|
||||
"token": map[string]any{"access_token": "tok-nested"},
|
||||
},
|
||||
"tok-nested",
|
||||
},
|
||||
{
|
||||
"top-level takes precedence over nested",
|
||||
map[string]any{
|
||||
"access_token": "tok-top",
|
||||
"token": map[string]any{"access_token": "tok-nested"},
|
||||
},
|
||||
"tok-top",
|
||||
},
|
||||
{
|
||||
"empty metadata",
|
||||
map[string]any{},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"whitespace-only access_token",
|
||||
map[string]any{"access_token": " "},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"wrong type access_token",
|
||||
map[string]any{"access_token": 12345},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"token is not a map",
|
||||
map[string]any{"token": "not-a-map"},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"nested whitespace-only",
|
||||
map[string]any{
|
||||
"token": map[string]any{"access_token": " "},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"fallback to nested when top-level empty",
|
||||
map[string]any{
|
||||
"access_token": "",
|
||||
"token": map[string]any{"access_token": "tok-fallback"},
|
||||
},
|
||||
"tok-fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := extractAccessToken(tt.metadata)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractAccessToken() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreSaveExistingMetadataSetsFileAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingToken string
|
||||
savedToken string
|
||||
}{
|
||||
{name: "unchanged content", existingToken: "token", savedToken: "token"},
|
||||
{name: "overwritten content", existingToken: "old-token", savedToken: "new-token"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
fileName := "antigravity-user.json"
|
||||
path := filepath.Join(baseDir, fileName)
|
||||
existing := []byte(`{"type":"antigravity","access_token":"` + tt.existingToken + `","disabled":false}`)
|
||||
if errWrite := os.WriteFile(path, existing, 0o600); errWrite != nil {
|
||||
t.Fatalf("write existing auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: fileName,
|
||||
FileName: fileName,
|
||||
Metadata: map[string]any{
|
||||
"type": "antigravity",
|
||||
"access_token": tt.savedToken,
|
||||
},
|
||||
}
|
||||
|
||||
savedPath, errSave := store.Save(context.Background(), auth)
|
||||
if errSave != nil {
|
||||
t.Fatalf("Save() error = %v", errSave)
|
||||
}
|
||||
if savedPath != path {
|
||||
t.Fatalf("Save() path = %q, want %q", savedPath, path)
|
||||
}
|
||||
if got := auth.Attributes[cliproxyauth.AttributePath]; got != path {
|
||||
t.Errorf("path attribute = %q, want %q", got, path)
|
||||
}
|
||||
if got := auth.Attributes[cliproxyauth.AttributeSource]; got != path {
|
||||
t.Errorf("source attribute = %q, want %q", got, path)
|
||||
}
|
||||
if got := auth.Attributes[cliproxyauth.AttributeSourceBackend]; got != cliproxyauth.AuthSourceFile {
|
||||
t.Errorf("source backend attribute = %q, want %q", got, cliproxyauth.AuthSourceFile)
|
||||
}
|
||||
persisted, errRead := os.ReadFile(path)
|
||||
if errRead != nil {
|
||||
t.Fatalf("read saved auth file: %v", errRead)
|
||||
}
|
||||
expected := []byte(`{"type":"antigravity","access_token":"` + tt.savedToken + `","disabled":false}`)
|
||||
if !jsonEqual(persisted, expected) {
|
||||
t.Errorf("saved auth file = %s, want JSON equal to %s", persisted, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreNormalizesLegacyCredentialMetadata(t *testing.T) {
|
||||
t.Run("save", func(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "legacy-save.json",
|
||||
FileName: "legacy-save.json",
|
||||
Metadata: map[string]any{
|
||||
"type": "codex",
|
||||
"request-retry": 2,
|
||||
"request_retry": 0,
|
||||
"disable-cooling": true,
|
||||
},
|
||||
}
|
||||
|
||||
path, errSave := store.Save(context.Background(), auth)
|
||||
if errSave != nil {
|
||||
t.Fatalf("Save() error = %v", errSave)
|
||||
}
|
||||
persisted, errRead := os.ReadFile(path)
|
||||
if errRead != nil {
|
||||
t.Fatalf("read saved auth file: %v", errRead)
|
||||
}
|
||||
want := []byte(`{"type":"codex","request_retry":0,"disable_cooling":true,"disabled":false}`)
|
||||
if !jsonEqual(persisted, want) {
|
||||
t.Fatalf("saved auth file = %s, want JSON equal to %s", persisted, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "legacy-list.json")
|
||||
if errWrite := os.WriteFile(path, []byte(`{"type":"codex","request-retry":2,"disable-cooling":true}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("write legacy auth file: %v", errWrite)
|
||||
}
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
|
||||
auths, errList := store.List(context.Background())
|
||||
if errList != nil {
|
||||
t.Fatalf("List() error = %v", errList)
|
||||
}
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("List() len = %d, want 1", len(auths))
|
||||
}
|
||||
if got := auths[0].Metadata["request_retry"]; got != float64(2) {
|
||||
t.Fatalf("listed request_retry = %#v, want 2", got)
|
||||
}
|
||||
if got := auths[0].Metadata["disable_cooling"]; got != true {
|
||||
t.Fatalf("listed disable_cooling = %#v, want true", got)
|
||||
}
|
||||
for _, legacy := range []string{"request-retry", "disable-cooling"} {
|
||||
if _, exists := auths[0].Metadata[legacy]; exists {
|
||||
t.Fatalf("listed metadata retained %q: %#v", legacy, auths[0].Metadata)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileTokenStoreSaveRejectsInvalidWeight(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "invalid.json",
|
||||
FileName: "invalid.json",
|
||||
Metadata: map[string]any{
|
||||
"type": "test",
|
||||
cliproxyauth.AttributeWeight: 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
if _, errSave := store.Save(context.Background(), auth); errSave == nil {
|
||||
t.Fatal("Save() accepted an invalid weight")
|
||||
}
|
||||
if _, errStat := os.Stat(filepath.Join(baseDir, auth.FileName)); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("invalid auth file was persisted: %v", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreListSkipsInvalidPluginSourceWeight(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "plugin.json")
|
||||
if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","weight":"invalid"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
parserCalled := false
|
||||
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
parserCalled = true
|
||||
return []*cliproxyauth.Auth{{ID: "plugin.json", Provider: "plugin"}}, true, nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
RegisterPluginAuthParser(nil)
|
||||
})
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auths, errList := store.List(context.Background())
|
||||
if errList != nil {
|
||||
t.Fatalf("List() error = %v", errList)
|
||||
}
|
||||
if parserCalled {
|
||||
t.Fatal("plugin parser was called for an invalid persisted source")
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("List() returned invalid plugin auths: %#v", auths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreListExpandsPluginMultiAuths(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "geminicli.json")
|
||||
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","weight":3,"headers":{"X-Test":"value"}}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
if req.Provider != "gemini-cli" || req.Path != path || req.FileName != "geminicli.json" {
|
||||
t.Fatalf("ParseAuths request = %#v, want file context", req)
|
||||
}
|
||||
return []*cliproxyauth.Auth{
|
||||
{
|
||||
ID: "geminicli.json",
|
||||
Provider: "gemini-cli",
|
||||
Metadata: map[string]any{
|
||||
"type": "gemini-cli",
|
||||
"headers": map[string]any{
|
||||
"X-Test": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
{
|
||||
ID: "geminicli-project-a.json",
|
||||
Provider: "gemini-cli",
|
||||
Metadata: map[string]any{
|
||||
"type": "gemini-cli",
|
||||
"project_id": "project-a",
|
||||
"headers": map[string]any{
|
||||
"X-Test": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, true, nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
RegisterPluginAuthParser(nil)
|
||||
})
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auths, errList := store.List(context.Background())
|
||||
if errList != nil {
|
||||
t.Fatalf("List() error = %v", errList)
|
||||
}
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("List() len = %d, want two plugin auths", len(auths))
|
||||
}
|
||||
if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex {
|
||||
t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex)
|
||||
}
|
||||
for _, auth := range auths {
|
||||
if !cliproxyauth.IsPluginVirtualAuth(auth) {
|
||||
t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes)
|
||||
}
|
||||
if auth.Attributes[cliproxyauth.AttributeVirtualSource] != path {
|
||||
t.Fatalf("virtual_source = %q, want %q", auth.Attributes[cliproxyauth.AttributeVirtualSource], path)
|
||||
}
|
||||
if auth.Attributes["path"] != path || auth.Attributes["source"] != path {
|
||||
t.Fatalf("auth attributes = %#v, want source path", auth.Attributes)
|
||||
}
|
||||
if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" {
|
||||
t.Fatalf("header:X-Test = %q, want value", gotHeader)
|
||||
}
|
||||
if gotWeight := auth.Attributes[cliproxyauth.AttributeWeight]; gotWeight != "3" {
|
||||
t.Fatalf("weight = %q, want 3", gotWeight)
|
||||
}
|
||||
}
|
||||
if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" {
|
||||
t.Fatalf("project_id = %#v, want project-a", gotProject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreListAppliesSourceDisabledToPluginMultiAuths(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "geminicli.json")
|
||||
if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","disabled":true}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
return []*cliproxyauth.Auth{
|
||||
{ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}},
|
||||
{ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}},
|
||||
}, true, nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
RegisterPluginAuthParser(nil)
|
||||
})
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auths, errList := store.List(context.Background())
|
||||
if errList != nil {
|
||||
t.Fatalf("List() error = %v", errList)
|
||||
}
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("List() len = %d, want two plugin auths", len(auths))
|
||||
}
|
||||
for _, auth := range auths {
|
||||
if !auth.Disabled || auth.Status != cliproxyauth.StatusDisabled {
|
||||
t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status)
|
||||
}
|
||||
if got, _ := auth.Metadata["disabled"].(bool); !got {
|
||||
t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTokenStoreListPluginHandledEmptySuppressesBuiltin(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
path := filepath.Join(baseDir, "codex.json")
|
||||
if errWrite := os.WriteFile(path, []byte(`{"type":"codex","access_token":"token"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
return nil, true, nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
RegisterPluginAuthParser(nil)
|
||||
})
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(baseDir)
|
||||
auths, errList := store.List(context.Background())
|
||||
if errList != nil {
|
||||
t.Fatalf("List() error = %v", errList)
|
||||
}
|
||||
if len(auths) != 0 {
|
||||
t.Fatalf("List() len = %d, want plugin-handled empty result", len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
type fileStoreMultiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error)
|
||||
|
||||
func (f fileStoreMultiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (f fileStoreMultiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) {
|
||||
return f(ctx, req)
|
||||
}
|
||||
29
backend/sdk/auth/interfaces.go
Normal file
29
backend/sdk/auth/interfaces.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
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
|
||||
CallbackPort int
|
||||
Metadata map[string]string
|
||||
Prompt func(prompt string) (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) (*coreauth.Auth, error)
|
||||
RefreshLead() *time.Duration
|
||||
}
|
||||
123
backend/sdk/auth/kimi.go
Normal file
123
backend/sdk/auth/kimi.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// kimiRefreshLead is the duration before token expiry when refresh should occur.
|
||||
var kimiRefreshLead = 5 * time.Minute
|
||||
|
||||
// KimiAuthenticator implements the OAuth device flow login for Kimi (Moonshot AI).
|
||||
type KimiAuthenticator struct{}
|
||||
|
||||
// NewKimiAuthenticator constructs a new Kimi authenticator.
|
||||
func NewKimiAuthenticator() Authenticator {
|
||||
return &KimiAuthenticator{}
|
||||
}
|
||||
|
||||
// Provider returns the provider key for kimi.
|
||||
func (KimiAuthenticator) Provider() string {
|
||||
return "kimi"
|
||||
}
|
||||
|
||||
// RefreshLead returns the duration before token expiry when refresh should occur.
|
||||
// Kimi tokens expire and need to be refreshed before expiry.
|
||||
func (KimiAuthenticator) RefreshLead() *time.Duration {
|
||||
return &kimiRefreshLead
|
||||
}
|
||||
|
||||
// Login initiates the Kimi device flow authentication.
|
||||
func (a KimiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
authSvc := kimi.NewKimiAuth(cfg)
|
||||
|
||||
// Start the device flow
|
||||
fmt.Println("Starting Kimi authentication...")
|
||||
deviceCode, err := authSvc.StartDeviceFlow(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: failed to start device flow: %w", err)
|
||||
}
|
||||
|
||||
// Display the verification URL
|
||||
verificationURL := deviceCode.VerificationURIComplete
|
||||
if verificationURL == "" {
|
||||
verificationURL = deviceCode.VerificationURI
|
||||
}
|
||||
|
||||
fmt.Printf("\nTo authenticate, please visit:\n%s\n\n", verificationURL)
|
||||
if deviceCode.UserCode != "" {
|
||||
fmt.Printf("User code: %s\n\n", deviceCode.UserCode)
|
||||
}
|
||||
|
||||
// Try to open the browser automatically
|
||||
if !opts.NoBrowser {
|
||||
if browser.IsAvailable() {
|
||||
if errOpen := browser.OpenURL(verificationURL); errOpen != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", errOpen)
|
||||
} else {
|
||||
fmt.Println("Browser opened automatically.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for authorization...")
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
|
||||
}
|
||||
|
||||
// Wait for user authorization
|
||||
authBundle, err := authSvc.WaitForAuthorization(ctx, deviceCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kimi: %w", err)
|
||||
}
|
||||
|
||||
// Create the token storage
|
||||
tokenStorage := authSvc.CreateTokenStorage(authBundle)
|
||||
|
||||
// Build metadata with token information
|
||||
metadata := map[string]any{
|
||||
"type": "kimi",
|
||||
"access_token": authBundle.TokenData.AccessToken,
|
||||
"refresh_token": authBundle.TokenData.RefreshToken,
|
||||
"token_type": authBundle.TokenData.TokenType,
|
||||
"scope": authBundle.TokenData.Scope,
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
if authBundle.TokenData.ExpiresAt > 0 {
|
||||
exp := time.Unix(authBundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||
metadata["expired"] = exp
|
||||
}
|
||||
if strings.TrimSpace(authBundle.DeviceID) != "" {
|
||||
metadata["device_id"] = strings.TrimSpace(authBundle.DeviceID)
|
||||
}
|
||||
|
||||
// Generate a unique filename
|
||||
fileName := fmt.Sprintf("kimi-%d.json", time.Now().UnixMilli())
|
||||
|
||||
fmt.Println("\nKimi authentication successful!")
|
||||
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Label: "Kimi User",
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
95
backend/sdk/auth/manager.go
Normal file
95
backend/sdk/auth/manager.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// Manager aggregates authenticators and coordinates persistence via a token store.
|
||||
type Manager struct {
|
||||
authenticators map[string]Authenticator
|
||||
store coreauth.Store
|
||||
}
|
||||
|
||||
// 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 coreauth.Store, 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 coreauth.Store) {
|
||||
m.store = store
|
||||
}
|
||||
|
||||
// Login executes the provider login flow and persists the resulting auth record.
|
||||
func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, 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
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
if dirSetter, ok := m.store.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
if strings.TrimSpace(cfg.AuthDir) != "" {
|
||||
targetFile := record.FileName
|
||||
if targetFile == "" {
|
||||
targetFile = record.ID
|
||||
}
|
||||
if targetFile != "" {
|
||||
fullPath := filepath.Join(cfg.AuthDir, targetFile)
|
||||
if raw, errRead := os.ReadFile(fullPath); errRead == nil && len(raw) > 0 {
|
||||
var existingMap map[string]any
|
||||
if errUnmarshal := json.Unmarshal(raw, &existingMap); errUnmarshal == nil && len(existingMap) > 0 {
|
||||
coreauth.MergeExistingAuthMetadata(record, existingMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savedPath, err := m.store.Save(ctx, record)
|
||||
if err != nil {
|
||||
return record, "", err
|
||||
}
|
||||
return record, savedPath, nil
|
||||
}
|
||||
111
backend/sdk/auth/manager_test.go
Normal file
111
backend/sdk/auth/manager_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
type dummyAuthenticator struct {
|
||||
provider string
|
||||
record *coreauth.Auth
|
||||
}
|
||||
|
||||
func (d *dummyAuthenticator) Provider() string {
|
||||
return d.provider
|
||||
}
|
||||
|
||||
func (d *dummyAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
return d.record, nil
|
||||
}
|
||||
|
||||
func (d *dummyAuthenticator) RefreshLead() *time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestManagerLogin_PreservesExistingAuthFileMetadata(t *testing.T) {
|
||||
authDir := t.TempDir()
|
||||
fileName := "demo.json"
|
||||
filePath := filepath.Join(authDir, fileName)
|
||||
|
||||
// Pre-populate existing auth file with custom settings
|
||||
existing := map[string]any{
|
||||
"type": "demo",
|
||||
"email": "user@example.com",
|
||||
"access_token": "old-token",
|
||||
"prefix": "my-prefix",
|
||||
"websockets": false,
|
||||
"note": "important note",
|
||||
"weight": float64(10),
|
||||
}
|
||||
raw, errMarshal := json.Marshal(existing)
|
||||
if errMarshal != nil {
|
||||
t.Fatalf("marshal error: %v", errMarshal)
|
||||
}
|
||||
if errWrite := os.WriteFile(filePath, raw, 0o600); errWrite != nil {
|
||||
t.Fatalf("write error: %v", errWrite)
|
||||
}
|
||||
|
||||
newRecord := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
FileName: fileName,
|
||||
Provider: "demo",
|
||||
Metadata: map[string]any{
|
||||
"type": "demo",
|
||||
"email": "user@example.com",
|
||||
"access_token": "new-token",
|
||||
},
|
||||
}
|
||||
|
||||
store := NewFileTokenStore()
|
||||
store.SetBaseDir(authDir)
|
||||
|
||||
auth := &dummyAuthenticator{
|
||||
provider: "demo",
|
||||
record: newRecord,
|
||||
}
|
||||
|
||||
mgr := NewManager(store, auth)
|
||||
cfg := &config.Config{
|
||||
AuthDir: authDir,
|
||||
}
|
||||
|
||||
_, savedPath, errLogin := mgr.Login(context.Background(), "demo", cfg, nil)
|
||||
if errLogin != nil {
|
||||
t.Fatalf("Login error: %v", errLogin)
|
||||
}
|
||||
if savedPath != filePath {
|
||||
t.Fatalf("savedPath = %s, want %s", savedPath, filePath)
|
||||
}
|
||||
|
||||
savedRaw, errRead := os.ReadFile(filePath)
|
||||
if errRead != nil {
|
||||
t.Fatalf("ReadFile error: %v", errRead)
|
||||
}
|
||||
var saved map[string]any
|
||||
if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
|
||||
t.Fatalf("Unmarshal error: %v", errUnmarshal)
|
||||
}
|
||||
|
||||
if saved["access_token"] != "new-token" {
|
||||
t.Errorf("access_token = %v, want new-token", saved["access_token"])
|
||||
}
|
||||
if saved["prefix"] != "my-prefix" {
|
||||
t.Errorf("prefix = %v, want my-prefix", saved["prefix"])
|
||||
}
|
||||
if saved["websockets"] != false {
|
||||
t.Errorf("websockets = %v, want false", saved["websockets"])
|
||||
}
|
||||
if saved["note"] != "important note" {
|
||||
t.Errorf("note = %v, want important note", saved["note"])
|
||||
}
|
||||
if saved["weight"] != float64(10) {
|
||||
t.Errorf("weight = %v, want 10", saved["weight"])
|
||||
}
|
||||
}
|
||||
28
backend/sdk/auth/refresh_registry.go
Normal file
28
backend/sdk/auth/refresh_registry.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() })
|
||||
registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() })
|
||||
registerRefreshLead("antigravity", func() Authenticator { return NewAntigravityAuthenticator() })
|
||||
registerRefreshLead("kimi", func() Authenticator { return NewKimiAuthenticator() })
|
||||
registerRefreshLead("xai", func() Authenticator { return NewXAIAuthenticator() })
|
||||
}
|
||||
|
||||
func registerRefreshLead(provider string, factory func() Authenticator) {
|
||||
cliproxyauth.RegisterRefreshLeadProvider(provider, func() *time.Duration {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
auth := factory()
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
return auth.RefreshLead()
|
||||
})
|
||||
}
|
||||
35
backend/sdk/auth/store_registry.go
Normal file
35
backend/sdk/auth/store_registry.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
storeMu sync.RWMutex
|
||||
registeredStore coreauth.Store
|
||||
)
|
||||
|
||||
// RegisterTokenStore sets the global token store used by the authentication helpers.
|
||||
func RegisterTokenStore(store coreauth.Store) {
|
||||
storeMu.Lock()
|
||||
registeredStore = store
|
||||
storeMu.Unlock()
|
||||
}
|
||||
|
||||
// GetTokenStore returns the globally registered token store.
|
||||
func GetTokenStore() coreauth.Store {
|
||||
storeMu.RLock()
|
||||
s := registeredStore
|
||||
storeMu.RUnlock()
|
||||
if s != nil {
|
||||
return s
|
||||
}
|
||||
storeMu.Lock()
|
||||
defer storeMu.Unlock()
|
||||
if registeredStore == nil {
|
||||
registeredStore = NewFileTokenStore()
|
||||
}
|
||||
return registeredStore
|
||||
}
|
||||
132
backend/sdk/auth/xai.go
Normal file
132
backend/sdk/auth/xai.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// XAIAuthenticator implements the xAI Grok OAuth device-code flow.
|
||||
type XAIAuthenticator struct{}
|
||||
|
||||
// NewXAIAuthenticator constructs a new xAI authenticator.
|
||||
func NewXAIAuthenticator() Authenticator {
|
||||
return &XAIAuthenticator{}
|
||||
}
|
||||
|
||||
// Provider returns the provider key for xAI.
|
||||
func (XAIAuthenticator) Provider() string {
|
||||
return "xai"
|
||||
}
|
||||
|
||||
// RefreshLead instructs the manager to refresh before token expiry.
|
||||
func (XAIAuthenticator) RefreshLead() *time.Duration {
|
||||
lead := xaiauth.RefreshLead()
|
||||
return &lead
|
||||
}
|
||||
|
||||
// Login launches the OAuth device-code flow to obtain xAI tokens and persists them.
|
||||
func (a XAIAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, 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 := xaiauth.NewXAIAuth(cfg)
|
||||
|
||||
fmt.Println("Starting xAI authentication...")
|
||||
deviceCode, err := authSvc.StartDeviceFlow(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xai: failed to start device flow: %w", err)
|
||||
}
|
||||
|
||||
verificationURL := strings.TrimSpace(deviceCode.VerificationURIComplete)
|
||||
if verificationURL == "" {
|
||||
verificationURL = strings.TrimSpace(deviceCode.VerificationURI)
|
||||
}
|
||||
|
||||
fmt.Printf("\nTo authenticate, please visit:\n%s\n\n", verificationURL)
|
||||
if deviceCode.UserCode != "" {
|
||||
fmt.Printf("Then enter this code: %s\n\n", deviceCode.UserCode)
|
||||
}
|
||||
|
||||
if !opts.NoBrowser {
|
||||
if browser.IsAvailable() {
|
||||
if errOpen := browser.OpenURL(verificationURL); errOpen != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", errOpen)
|
||||
} else {
|
||||
fmt.Println("Browser opened automatically.")
|
||||
}
|
||||
} else {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Waiting for authorization...")
|
||||
if deviceCode.ExpiresIn > 0 {
|
||||
fmt.Printf("(This will timeout in %d seconds if not authorized)\n", deviceCode.ExpiresIn)
|
||||
}
|
||||
|
||||
bundle, errWait := authSvc.WaitForAuthorization(ctx, deviceCode)
|
||||
if errWait != nil {
|
||||
return nil, fmt.Errorf("xai: %w", errWait)
|
||||
}
|
||||
|
||||
tokenStorage := authSvc.CreateTokenStorage(bundle)
|
||||
if tokenStorage == nil || strings.TrimSpace(tokenStorage.AccessToken) == "" {
|
||||
return nil, fmt.Errorf("xai token storage missing access token")
|
||||
}
|
||||
|
||||
fileName := xaiauth.CredentialFileName(tokenStorage.Email, tokenStorage.Subject)
|
||||
label := strings.TrimSpace(tokenStorage.Email)
|
||||
if label == "" {
|
||||
label = "xAI"
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"type": "xai",
|
||||
"access_token": tokenStorage.AccessToken,
|
||||
"refresh_token": tokenStorage.RefreshToken,
|
||||
"id_token": tokenStorage.IDToken,
|
||||
"token_type": tokenStorage.TokenType,
|
||||
"expires_in": tokenStorage.ExpiresIn,
|
||||
"expired": tokenStorage.Expire,
|
||||
"last_refresh": tokenStorage.LastRefresh,
|
||||
"base_url": tokenStorage.BaseURL,
|
||||
"token_endpoint": tokenStorage.TokenEndpoint,
|
||||
"auth_kind": "oauth",
|
||||
}
|
||||
if tokenStorage.Email != "" {
|
||||
metadata["email"] = tokenStorage.Email
|
||||
}
|
||||
if tokenStorage.Subject != "" {
|
||||
metadata["sub"] = tokenStorage.Subject
|
||||
}
|
||||
|
||||
fmt.Println("xAI authentication successful")
|
||||
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Label: label,
|
||||
Storage: tokenStorage,
|
||||
Metadata: metadata,
|
||||
Attributes: map[string]string{
|
||||
"auth_kind": "oauth",
|
||||
"base_url": tokenStorage.BaseURL,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
14
backend/sdk/auth/xai_test.go
Normal file
14
backend/sdk/auth/xai_test.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestXAIAuthenticatorProviderAndRefreshLead(t *testing.T) {
|
||||
authenticator := NewXAIAuthenticator()
|
||||
if authenticator.Provider() != "xai" {
|
||||
t.Fatalf("Provider() = %q, want xai", authenticator.Provider())
|
||||
}
|
||||
lead := authenticator.RefreshLead()
|
||||
if lead == nil || *lead <= 0 {
|
||||
t.Fatalf("RefreshLead() = %v, want positive duration", lead)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue