diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index 17f6873e..7ad57003 100644 --- a/MANAGEMENT_API.md +++ b/MANAGEMENT_API.md @@ -514,6 +514,56 @@ Manage JSON token files under `auth-dir`: list, download, upload, delete. { "status": "ok", "deleted": 3 } ``` +### Login/OAuth URLs + +These endpoints initiate provider login flows and return a URL to open in a browser. Tokens are saved under `auths/` once the flow completes. + +- GET `/anthropic-auth-url` — Start Anthropic (Claude) login + - Request: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/anthropic-auth-url + ``` + - Response: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/codex-auth-url` — Start Codex login + - Request: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/codex-auth-url + ``` + - Response: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/gemini-cli-auth-url` — Start Google (Gemini CLI) login + - Query params: + - `project_id` (optional): Google Cloud project ID. + - Request: + ```bash + curl -H 'Authorization: Bearer ' \ + 'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=' + ``` + - Response: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/qwen-auth-url` — Start Qwen login (device flow) + - Request: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/qwen-auth-url + ``` + - Response: + ```json + { "status": "ok", "url": "https://..." } + ``` + ## Error Responses Generic error format: @@ -527,4 +577,3 @@ Generic error format: - Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients. - `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file. - diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index b4a661fa..85c77d60 100644 --- a/MANAGEMENT_API_CN.md +++ b/MANAGEMENT_API_CN.md @@ -514,6 +514,56 @@ { "status": "ok", "deleted": 3 } ``` +### 登录/授权 URL + +以下端点用于发起各提供商的登录流程,并返回需要在浏览器中打开的 URL。流程完成后,令牌会保存到 `auths/` 目录。 + +- GET `/anthropic-auth-url` — 开始 Anthropic(Claude)登录 + - 请求: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/anthropic-auth-url + ``` + - 响应: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/codex-auth-url` — 开始 Codex 登录 + - 请求: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/codex-auth-url + ``` + - 响应: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/gemini-cli-auth-url` — 开始 Google(Gemini CLI)登录 + - 查询参数: + - `project_id`(可选):Google Cloud 项目 ID。 + - 请求: + ```bash + curl -H 'Authorization: Bearer ' \ + 'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=' + ``` + - 响应: + ```json + { "status": "ok", "url": "https://..." } + ``` + +- GET `/qwen-auth-url` — 开始 Qwen 登录(设备授权流程) + - 请求: + ```bash + curl -H 'Authorization: Bearer ' \ + http://localhost:8317/v0/management/qwen-auth-url + ``` + - 响应: + ```json + { "status": "ok", "url": "https://..." } + ``` + ## 错误响应 通用错误格式: @@ -527,4 +577,3 @@ - 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。 - `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。 - diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 6337b3da..d061c04a 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1,14 +1,27 @@ package management import ( + "context" + "encoding/json" "fmt" "io" + "net/http" "os" "path/filepath" "strings" + "time" "github.com/gin-gonic/gin" + "github.com/luispater/CLIProxyAPI/internal/auth/claude" + "github.com/luispater/CLIProxyAPI/internal/auth/codex" + geminiAuth "github.com/luispater/CLIProxyAPI/internal/auth/gemini" + "github.com/luispater/CLIProxyAPI/internal/auth/qwen" + "github.com/luispater/CLIProxyAPI/internal/client" + "github.com/luispater/CLIProxyAPI/internal/misc" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" ) // List auth files @@ -147,3 +160,480 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { } c.JSON(200, gin.H{"status": "ok"}) } + +func (h *Handler) RequestAnthropicToken(c *gin.Context) { + ctx := context.Background() + + log.Info("Initializing Claude authentication...") + + // Generate PKCE codes + pkceCodes, err := claude.GeneratePKCECodes() + if err != nil { + log.Fatalf("Failed to generate PKCE codes: %v", err) + return + } + + // Generate random state parameter + state, err := misc.GenerateRandomState() + if err != nil { + log.Fatalf("Failed to generate state parameter: %v", err) + return + } + + // Initialize Claude auth service + anthropicAuth := claude.NewClaudeAuth(h.cfg) + + // Generate authorization URL + authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes) + if err != nil { + log.Fatalf("Failed to generate authorization URL: %v", err) + return + } + + go func() { + // Initialize OAuth server + oauthServer := claude.NewOAuthServer(54545) + + // Start OAuth callback server + if err = oauthServer.Start(); err != nil { + if strings.Contains(err.Error(), "already in use") { + authErr := claude.NewAuthenticationError(claude.ErrPortInUse, err) + log.Error(claude.GetUserFriendlyMessage(authErr)) + return + } + authErr := claude.NewAuthenticationError(claude.ErrServerStartFailed, err) + log.Fatalf("Failed to start OAuth callback server: %v", authErr) + return + } + defer func() { + if err = oauthServer.Stop(ctx); err != nil { + log.Warnf("Failed to stop OAuth server: %v", err) + } + }() + + log.Info("Waiting for authentication callback...") + + // Wait for OAuth callback + result, errWaitForCallback := oauthServer.WaitForCallback(5 * time.Minute) + if errWaitForCallback != nil { + if strings.Contains(errWaitForCallback.Error(), "timeout") { + authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWaitForCallback) + log.Error(claude.GetUserFriendlyMessage(authErr)) + } else { + log.Errorf("Authentication failed: %v", errWaitForCallback) + } + return + } + + if result.Error != "" { + oauthErr := claude.NewOAuthError(result.Error, "", http.StatusBadRequest) + log.Error(claude.GetUserFriendlyMessage(oauthErr)) + return + } + + // Validate state parameter + if result.State != state { + authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, result.State)) + log.Error(claude.GetUserFriendlyMessage(authErr)) + return + } + + log.Debug("Authorization code received, exchanging for tokens...") + + // Exchange authorization code for tokens + authBundle, errExchangeCodeForTokens := anthropicAuth.ExchangeCodeForTokens(ctx, result.Code, state, pkceCodes) + if errExchangeCodeForTokens != nil { + authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchangeCodeForTokens) + log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) + log.Debug("This may be due to network issues or invalid authorization code") + return + } + + // Create token storage + tokenStorage := anthropicAuth.CreateTokenStorage(authBundle) + + // Initialize Claude client + anthropicClient := client.NewClaudeClient(h.cfg, tokenStorage) + + // Save token storage + if errWaitForCallback = anthropicClient.SaveTokenToFile(); errWaitForCallback != nil { + log.Fatalf("Failed to save authentication tokens: %v", errWaitForCallback) + return + } + + log.Info("Authentication successful!") + if authBundle.APIKey != "" { + log.Info("API key obtained and saved") + } + + log.Info("You can now use Claude services through this CLI") + }() + + c.JSON(200, gin.H{"status": "ok", "url": authURL}) +} + +func (h *Handler) RequestGeminiCLIToken(c *gin.Context) { + ctx := context.Background() + + // Optional project ID from query + projectID := c.Query("project_id") + + log.Info("Initializing Google authentication...") + + // OAuth2 configuration (mirrors internal/auth/gemini) + conf := &oauth2.Config{ + ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + ClientSecret: "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", + RedirectURL: "http://localhost:8085/oauth2callback", + Scopes: []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + }, + Endpoint: google.Endpoint, + } + + // Build authorization URL and return it immediately + authURL := conf.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) + + go func() { + codeChan := make(chan string) + errChan := make(chan error) + + mux := http.NewServeMux() + server := &http.Server{Addr: ":8085", Handler: mux} + + mux.HandleFunc("/oauth2callback", func(w http.ResponseWriter, r *http.Request) { + if err := r.URL.Query().Get("error"); err != "" { + _, _ = fmt.Fprintf(w, "Authentication failed: %s", err) + errChan <- fmt.Errorf("authentication failed via callback: %s", err) + return + } + code := r.URL.Query().Get("code") + if code == "" { + _, _ = fmt.Fprint(w, "Authentication failed: code not found.") + errChan <- fmt.Errorf("code not found in callback") + return + } + _, _ = fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + codeChan <- code + }) + + go func() { + if errListen := server.ListenAndServe(); errListen != nil && errListen != http.ErrServerClosed { + log.Fatalf("ListenAndServe(): %v", errListen) + } + }() + + log.Info("Waiting for authentication callback...") + + var authCode string + select { + case code := <-codeChan: + authCode = code + case errCallback := <-errChan: + log.Errorf("Authentication failed: %v", errCallback) + // Attempt graceful shutdown + if errShutdown := server.Shutdown(ctx); errShutdown != nil { + log.Warnf("Failed to shut down server: %v", errShutdown) + } + return + case <-time.After(5 * time.Minute): + log.Error("oauth flow timed out") + if errShutdown := server.Shutdown(ctx); errShutdown != nil { + log.Warnf("Failed to shut down server after timeout: %v", errShutdown) + } + return + } + + // Shutdown the callback server after receiving the code + if errShutdown := server.Shutdown(ctx); errShutdown != nil { + log.Warnf("Failed to shut down server: %v", errShutdown) + } + + // Exchange authorization code for token + token, err := conf.Exchange(ctx, authCode) + if err != nil { + log.Errorf("Failed to exchange token: %v", err) + return + } + + // Create token storage (mirrors internal/auth/gemini createTokenStorage) + httpClient := conf.Client(ctx, token) + req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) + if errNewRequest != nil { + log.Errorf("Could not get user info: %v", errNewRequest) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + + resp, errDo := httpClient.Do(req) + if errDo != nil { + log.Errorf("Failed to execute request: %v", errDo) + return + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Printf("warn: failed to close response body: %v", errClose) + } + }() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return + } + + email := gjson.GetBytes(bodyBytes, "email").String() + if email != "" { + log.Infof("Authenticated user email: %s", email) + } else { + log.Info("Failed to get user email from token") + } + + // Marshal/unmarshal oauth2.Token to generic map and enrich fields + var ifToken map[string]any + jsonData, _ := json.Marshal(token) + if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil { + log.Errorf("Failed to unmarshal token: %v", errUnmarshal) + return + } + + ifToken["token_uri"] = "https://oauth2.googleapis.com/token" + ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + ifToken["client_secret"] = "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" + ifToken["scopes"] = []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + ifToken["universe_domain"] = "googleapis.com" + + ts := geminiAuth.GeminiTokenStorage{ + Token: ifToken, + ProjectID: projectID, + Email: email, + } + + // Initialize authenticated HTTP client via GeminiAuth to honor proxy settings + gemAuth := geminiAuth.NewGeminiAuth() + httpClient2, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true) + if errGetClient != nil { + log.Fatalf("failed to get authenticated client: %v", errGetClient) + return + } + log.Info("Authentication successful.") + + // Initialize the API client + cliClient := client.NewGeminiCLIClient(httpClient2, &ts, h.cfg) + + // Perform the user setup process (migrated from DoLogin) + if err = cliClient.SetupUser(ctx, ts.Email, projectID); err != nil { + if err.Error() == "failed to start user onboarding, need define a project id" { + log.Error("Failed to start user onboarding: A project ID is required.") + project, errGetProjectList := cliClient.GetProjectList(ctx) + if errGetProjectList != nil { + log.Fatalf("Failed to get project list: %v", err) + } else { + log.Infof("Your account %s needs to specify a project ID.", ts.Email) + log.Info("========================================================================") + for _, p := range project.Projects { + log.Infof("Project ID: %s", p.ProjectID) + log.Infof("Project Name: %s", p.Name) + log.Info("------------------------------------------------------------------------") + } + log.Infof("Please run this command to login again with a specific project:\n\n%s --login --project_id \n", os.Args[0]) + } + } else { + log.Fatalf("Failed to complete user setup: %v", err) + } + return + } + + // Post-setup checks and token persistence + auto := projectID == "" + cliClient.SetIsAuto(auto) + if !cliClient.IsChecked() && !cliClient.IsAuto() { + isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled() + if checkErr != nil { + log.Fatalf("Failed to check if Cloud AI API is enabled: %v", checkErr) + return + } + cliClient.SetIsChecked(isChecked) + if !isChecked { + log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.") + return + } + } + + if err = cliClient.SaveTokenToFile(); err != nil { + log.Fatalf("Failed to save token to file: %v", err) + return + } + + log.Info("You can now use Gemini CLI services through this CLI") + }() + + c.JSON(200, gin.H{"status": "ok", "url": authURL}) +} + +func (h *Handler) RequestCodexToken(c *gin.Context) { + ctx := context.Background() + + log.Info("Initializing Codex authentication...") + + // Generate PKCE codes + pkceCodes, err := codex.GeneratePKCECodes() + if err != nil { + log.Fatalf("Failed to generate PKCE codes: %v", err) + return + } + + // Generate random state parameter + state, err := misc.GenerateRandomState() + if err != nil { + log.Fatalf("Failed to generate state parameter: %v", err) + return + } + + // Initialize Codex auth service + openaiAuth := codex.NewCodexAuth(h.cfg) + + // Generate authorization URL + authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes) + if err != nil { + log.Fatalf("Failed to generate authorization URL: %v", err) + return + } + + go func() { + // Initialize OAuth server + oauthServer := codex.NewOAuthServer(1455) + + // Start OAuth callback server + if err = oauthServer.Start(); err != nil { + if strings.Contains(err.Error(), "already in use") { + authErr := codex.NewAuthenticationError(codex.ErrPortInUse, err) + log.Error(codex.GetUserFriendlyMessage(authErr)) + return + } + authErr := codex.NewAuthenticationError(codex.ErrServerStartFailed, err) + log.Fatalf("Failed to start OAuth callback server: %v", authErr) + return + } + defer func() { + if err = oauthServer.Stop(ctx); err != nil { + log.Warnf("Failed to stop OAuth server: %v", err) + } + }() + + log.Info("Waiting for authentication callback...") + + // Wait for OAuth callback + result, errWaitForCallback := oauthServer.WaitForCallback(5 * time.Minute) + if errWaitForCallback != nil { + if strings.Contains(errWaitForCallback.Error(), "timeout") { + authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, errWaitForCallback) + log.Error(codex.GetUserFriendlyMessage(authErr)) + } else { + log.Errorf("Authentication failed: %v", errWaitForCallback) + } + return + } + + if result.Error != "" { + oauthErr := codex.NewOAuthError(result.Error, "", http.StatusBadRequest) + log.Error(codex.GetUserFriendlyMessage(oauthErr)) + return + } + + // Validate state parameter + if result.State != state { + authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, result.State)) + log.Error(codex.GetUserFriendlyMessage(authErr)) + return + } + + log.Debug("Authorization code received, exchanging for tokens...") + + // Exchange authorization code for tokens + authBundle, errExchangeCodeForTokens := openaiAuth.ExchangeCodeForTokens(ctx, result.Code, pkceCodes) + if errExchangeCodeForTokens != nil { + authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchangeCodeForTokens) + log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) + log.Debug("This may be due to network issues or invalid authorization code") + return + } + + // Create token storage + tokenStorage := openaiAuth.CreateTokenStorage(authBundle) + + // Initialize Codex client + openaiClient, errWaitForCallback := client.NewCodexClient(h.cfg, tokenStorage) + if errWaitForCallback != nil { + log.Fatalf("Failed to initialize Codex client: %v", errWaitForCallback) + return + } + + // Save token storage + if errWaitForCallback = openaiClient.SaveTokenToFile(); errWaitForCallback != nil { + log.Fatalf("Failed to save authentication tokens: %v", errWaitForCallback) + return + } + + log.Info("Authentication successful!") + if authBundle.APIKey != "" { + log.Info("API key obtained and saved") + } + + log.Info("You can now use Codex services through this CLI") + }() + + c.JSON(200, gin.H{"status": "ok", "url": authURL}) +} + +func (h *Handler) RequestQwenToken(c *gin.Context) { + ctx := context.Background() + + log.Info("Initializing Qwen authentication...") + + // Initialize Qwen auth service + qwenAuth := qwen.NewQwenAuth(h.cfg) + + // Generate authorization URL + deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx) + if err != nil { + log.Fatalf("Failed to generate authorization URL: %v", err) + return + } + authURL := deviceFlow.VerificationURIComplete + + go func() { + log.Info("Waiting for authentication...") + tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier) + if errPollForToken != nil { + fmt.Printf("Authentication failed: %v\n", errPollForToken) + os.Exit(1) + } + + // Create token storage + tokenStorage := qwenAuth.CreateTokenStorage(tokenData) + + // Initialize Qwen client + qwenClient := client.NewQwenClient(h.cfg, tokenStorage) + + tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli()) + + // Save token storage + if err = qwenClient.SaveTokenToFile(); err != nil { + log.Fatalf("Failed to save authentication tokens: %v", err) + return + } + + log.Info("Authentication successful!") + log.Info("You can now use Qwen services through this CLI") + }() + + c.JSON(200, gin.H{"status": "ok", "url": authURL}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 65ba7fd8..57c7b92d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -211,6 +211,12 @@ func (s *Server) setupRoutes() { mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile) mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) + + mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) + mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken) + mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) + mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken) + } } } diff --git a/internal/cmd/anthropic_login.go b/internal/cmd/anthropic_login.go index f6e52c62..2249559f 100644 --- a/internal/cmd/anthropic_login.go +++ b/internal/cmd/anthropic_login.go @@ -15,6 +15,7 @@ import ( "github.com/luispater/CLIProxyAPI/internal/browser" "github.com/luispater/CLIProxyAPI/internal/client" "github.com/luispater/CLIProxyAPI/internal/config" + "github.com/luispater/CLIProxyAPI/internal/misc" "github.com/luispater/CLIProxyAPI/internal/util" log "github.com/sirupsen/logrus" ) @@ -44,7 +45,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) { } // Generate random state parameter - state, err := generateRandomState() + state, err := misc.GenerateRandomState() if err != nil { log.Fatalf("Failed to generate state parameter: %v", err) return diff --git a/internal/cmd/openai_login.go b/internal/cmd/openai_login.go index 4ebb218f..7d5ba5d2 100644 --- a/internal/cmd/openai_login.go +++ b/internal/cmd/openai_login.go @@ -5,8 +5,6 @@ package cmd import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "net/http" "os" @@ -17,6 +15,7 @@ import ( "github.com/luispater/CLIProxyAPI/internal/browser" "github.com/luispater/CLIProxyAPI/internal/client" "github.com/luispater/CLIProxyAPI/internal/config" + "github.com/luispater/CLIProxyAPI/internal/misc" "github.com/luispater/CLIProxyAPI/internal/util" log "github.com/sirupsen/logrus" ) @@ -52,7 +51,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { } // Generate random state parameter - state, err := generateRandomState() + state, err := misc.GenerateRandomState() if err != nil { log.Fatalf("Failed to generate state parameter: %v", err) return @@ -177,17 +176,3 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) { log.Info("You can now use Codex services through this CLI") } - -// generateRandomState generates a cryptographically secure random state parameter -// for OAuth2 flows to prevent CSRF attacks. -// -// Returns: -// - string: A hexadecimal encoded random state string -// - error: An error if the random generation fails, nil otherwise -func generateRandomState() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - return hex.EncodeToString(bytes), nil -} diff --git a/internal/misc/oauth.go b/internal/misc/oauth.go new file mode 100644 index 00000000..acf034b2 --- /dev/null +++ b/internal/misc/oauth.go @@ -0,0 +1,21 @@ +package misc + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// GenerateRandomState generates a cryptographically secure random state parameter +// for OAuth2 flows to prevent CSRF attacks. +// +// Returns: +// - string: A hexadecimal encoded random state string +// - error: An error if the random generation fails, nil otherwise +func GenerateRandomState() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return hex.EncodeToString(bytes), nil +}