Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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
View 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
View 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)
}

View 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
}

View 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
}

View 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
}
}

View 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))
}
}

View 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)
}

View 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
View 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
}

View 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
}

View 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"])
}
}

View 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()
})
}

View 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
View 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
}

View 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)
}
}