package eshop import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // RevolutAccountType represents the type of Revolut account type RevolutAccountType string const ( RevolutAccountTypePro RevolutAccountType = "revolut_pro" RevolutAccountTypeBusiness RevolutAccountType = "business" ) // RevolutOAuthToken represents the OAuth token response type RevolutOAuthToken struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token,omitempty"` Scope string `json:"scope"` } // RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts type RevolutOAuthService struct { cfg *config.Config client *http.Client } // RevolutOAuthConfig holds OAuth configuration for different account types type RevolutOAuthConfig struct { AccountType RevolutAccountType `json:"account_type"` ClientID string `json:"client_id"` AuthBaseURL string `json:"auth_base_url"` TokenURL string `json:"token_url"` APIBaseURL string `json:"api_base_url"` } // NewRevolutOAuthService creates a new OAuth service instance func NewRevolutOAuthService(cfg *config.Config) *RevolutOAuthService { return &RevolutOAuthService{ cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}, } } // GetOAuthConfig returns configuration for the specified account type and environment func (s *RevolutOAuthService) GetOAuthConfig(accountType RevolutAccountType) RevolutOAuthConfig { isSandbox := s.cfg.RevolutEnvironment == "sandbox" switch accountType { case RevolutAccountTypePro: if isSandbox { return RevolutOAuthConfig{ AccountType: RevolutAccountTypePro, ClientID: "sandbox_pro_client_id", AuthBaseURL: "https://sandbox-checkout.revolut.com", TokenURL: "https://sandbox-checkout.revolut.com/api/connect/oauth/token", APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0", } } else { return RevolutOAuthConfig{ AccountType: RevolutAccountTypePro, ClientID: "9cda975e-016c-4b49-b5c6-37d1285ba046", AuthBaseURL: "https://checkout.revolut.com", TokenURL: "https://checkout.revolut.com/api/connect/oauth/token", APIBaseURL: "https://merchant.revolut.com/api/1.0", } } case RevolutAccountTypeBusiness: if isSandbox { return RevolutOAuthConfig{ AccountType: RevolutAccountTypeBusiness, ClientID: "sandbox_business_client_id", AuthBaseURL: "https://sandbox-business.revolut.com", TokenURL: "https://sandbox-business.revolut.com/api/1.0/auth/token", APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0", } } else { return RevolutOAuthConfig{ AccountType: RevolutAccountTypeBusiness, ClientID: "diiToLZlMJOPtWhdFTxQ", AuthBaseURL: "https://business.revolut.com", TokenURL: "https://b2b.revolut.com/api/1.0/auth/token", APIBaseURL: "https://merchant.revolut.com/api/1.0", } } default: return s.GetOAuthConfig(RevolutAccountTypePro) } } // GenerateAuthURL generates the OAuth2 authorization URL for both account types func (s *RevolutOAuthService) GenerateAuthURL(accountType RevolutAccountType, state string, codeChallenge string) (string, error) { config := s.GetOAuthConfig(accountType) if accountType == RevolutAccountTypePro { params := map[string]string{ "client_id": config.ClientID, "redirect_uri": s.cfg.RevolutWebhookURL, "response_type": "code", "scope": "checkout_extension", "code_challenge_method": "S256", "code_challenge": codeChallenge, "response_mode": "query", "state": state, "integration_type": "CUSTOM_PLUGIN", "rwa_auth_type": "auth", } query := buildQueryString(params) return fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, query), nil } params := map[string]string{ "client_id": config.ClientID, "redirect_uri": s.cfg.RevolutWebhookURL, "response_type": "code", "code_challenge_method": "S256", "code_challenge": codeChallenge, "response_mode": "query", "prompt": "select_account", "state": state, } query := buildQueryString(params) return fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, query), nil } // buildQueryString builds a query string from a map func buildQueryString(params map[string]string) string { var parts []string for k, v := range params { parts = append(parts, fmt.Sprintf("%s=%s", k, v)) } return strings.Join(parts, "&") } // GenerateCodeVerifier generates a PKCE code verifier func GenerateCodeVerifier() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // GenerateCodeChallenge generates a PKCE code challenge from verifier func GenerateCodeChallenge(verifier string) string { h := sha256.New() h.Write([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } // ExchangeCodeForToken exchanges authorization code for access token func (s *RevolutOAuthService) ExchangeCodeForToken(accountType RevolutAccountType, code, codeVerifier string) (*RevolutOAuthToken, error) { config := s.GetOAuthConfig(accountType) data := map[string]string{ "client_id": config.ClientID, "code": code, "code_verifier": codeVerifier, "grant_type": "authorization_code", "redirect_uri": s.cfg.RevolutWebhookURL, } body := buildFormData(data) req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute token request: %w", err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token API error: status %d, response: %s", resp.StatusCode, string(bodyBytes)) } var token RevolutOAuthToken if err := json.Unmarshal(bodyBytes, &token); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType) return &token, nil } // buildFormData builds form data from a map func buildFormData(data map[string]string) string { var parts []string for k, v := range data { parts = append(parts, fmt.Sprintf("%s=%s", k, v)) } return strings.Join(parts, "&") } // StoreOAuthToken stores the OAuth token with account type information func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error { expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) logger.Info("Storing OAuth token for %s: expires at %v", accountType, expiresAt) return nil } // GetStoredOAuthToken retrieves stored OAuth token and account type func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) { return nil, "", fmt.Errorf("not implemented") } // RefreshAccessToken refreshes the access token using refresh token func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) { config := s.GetOAuthConfig(accountType) data := map[string]string{ "client_id": config.ClientID, "refresh_token": refreshToken, "grant_type": "refresh_token", } body := buildFormData(data) req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create refresh request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute refresh request: %w", err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read refresh response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("refresh API error: status %d, response: %s", resp.StatusCode, string(bodyBytes)) } var token RevolutOAuthToken if err := json.Unmarshal(bodyBytes, &token); err != nil { return nil, fmt.Errorf("failed to parse refresh response: %w", err) } token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType) return &token, nil } // RevolutOAuthController handles OAuth authentication for Revolut Pro type RevolutOAuthController struct { DB *gorm.DB Config *config.Config OAuthService *RevolutOAuthService } // NewRevolutOAuthController creates a new OAuth controller func NewRevolutOAuthController(db *gorm.DB, cfg *config.Config) *RevolutOAuthController { return &RevolutOAuthController{ DB: db, Config: cfg, OAuthService: NewRevolutOAuthService(cfg), } } // OAuthStart initiates the OAuth flow for both Revolut Pro and Business func (ctrl *RevolutOAuthController) OAuthStart(c *gin.Context) { // Get account type from request (pro or business) var req struct { AccountType string `json:"account_type" binding:"required,oneof=revolut_pro business"` } if err := c.ShouldBindJSON(&req); err != nil { logger.Error("Invalid OAuth start request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account type. Use 'revolut_pro' or 'business'"}) return } // Convert to RevolutAccountType var accountType RevolutAccountType if req.AccountType == "revolut_pro" { accountType = RevolutAccountTypePro } else { accountType = RevolutAccountTypeBusiness } // Generate PKCE verifier and challenge verifier, err := GenerateCodeVerifier() if err != nil { logger.Error("Failed to generate code verifier: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initiate OAuth"}) return } challenge := GenerateCodeChallenge(verifier) // Generate state token for security state := fmt.Sprintf("revolut_oauth_%s_%d", accountType, time.Now().UnixNano()) // Store verifier and state in session/temporary storage // For now, we'll use a simple approach - in production, use Redis or database // TODO: Store code_verifier, state, and account_type securely logger.Info("OAuth session created: state=%s, account_type=%s", state, accountType) // Generate authorization URL authURL, err := ctrl.OAuthService.GenerateAuthURL(accountType, state, challenge) if err != nil { logger.Error("Failed to generate auth URL: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authorization URL"}) return } // Return the authorization URL for frontend to redirect c.JSON(http.StatusOK, gin.H{ "authorization_url": authURL, "state": state, "account_type": req.AccountType, }) } // OAuthCallback handles the OAuth callback from Revolut func (ctrl *RevolutOAuthController) OAuthCallback(c *gin.Context) { // Get query parameters code := c.Query("code") state := c.Query("state") errorParam := c.Query("error") if errorParam != "" { logger.Error("OAuth error: %s", errorParam) c.JSON(http.StatusBadRequest, gin.H{"error": "OAuth authorization failed"}) return } if code == "" || state == "" { logger.Error("Missing OAuth callback parameters") c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"}) return } // TODO: Retrieve stored session data and verify state + account type // For now, we'll extract account type from state - in production, verify from secure storage var accountType RevolutAccountType = RevolutAccountTypePro // Default // Extract account type from state if available if strings.Contains(state, "business") { accountType = RevolutAccountTypeBusiness } // TODO: Retrieve code_verifier from session codeVerifier := "stored_code_verifier" // This should come from secure storage // Exchange code for access token token, err := ctrl.OAuthService.ExchangeCodeForToken(accountType, code, codeVerifier) if err != nil { logger.Error("Failed to exchange code for token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to obtain access token"}) return } // Store the OAuth token securely if err := ctrl.OAuthService.StoreOAuthToken(accountType, token); err != nil { logger.Error("Failed to store OAuth token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store access token"}) return } // Mark Revolut as configured if err := ctrl.updateRevolutConfig(true); err != nil { logger.Error("Failed to update Revolut config: %v", err) // Continue anyway - token is stored } logger.Info("Revolut OAuth authentication successful for %s", accountType) // Redirect to success page or return success response c.JSON(http.StatusOK, gin.H{ "success": true, "message": fmt.Sprintf("Revolut %s account connected successfully", accountType), "account_type": string(accountType), "token_info": map[string]interface{}{ "token_type": token.TokenType, "expires_in": token.ExpiresIn, "scope": token.Scope, }, }) } // OAuthStatus returns the current OAuth authentication status func (ctrl *RevolutOAuthController) OAuthStatus(c *gin.Context) { // Check if we have a stored OAuth token token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken() if err != nil { c.JSON(http.StatusOK, gin.H{ "authenticated": false, "message": "No Revolut account connected", }) return } // Check if token is still valid if token.ExpiresIn > 0 { // TODO: Check actual expiration time from stored data c.JSON(http.StatusOK, gin.H{ "authenticated": true, "account_type": string(accountType), "token_type": token.TokenType, "scope": token.Scope, "expires_in": token.ExpiresIn, }) return } c.JSON(http.StatusOK, gin.H{ "authenticated": false, "message": "Token expired, please re-authenticate", }) } // OAuthDisconnect removes the stored OAuth token func (ctrl *RevolutOAuthController) OAuthDisconnect(c *gin.Context) { // TODO: Remove stored OAuth token from database logger.Info("Revolut OAuth disconnected by user") // Mark Revolut as disabled if err := ctrl.updateRevolutConfig(false); err != nil { logger.Error("Failed to update Revolut config: %v", err) } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Revolut account disconnected", }) } // updateRevolutConfig updates the Revolut configuration in the database func (ctrl *RevolutOAuthController) updateRevolutConfig(enabled bool) error { // Update the main settings table var settings models.Settings if err := ctrl.DB.First(&settings).Error; err != nil { if err == gorm.ErrRecordNotFound { // Create settings if not found settings = models.Settings{ RevolutEnabled: enabled, } return ctrl.DB.Create(&settings).Error } return err } // Update existing settings settings.RevolutEnabled = enabled return ctrl.DB.Save(&settings).Error } // RefreshToken refreshes the OAuth access token func (ctrl *RevolutOAuthController) RefreshToken(c *gin.Context) { // Get current token and account type token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"}) return } if token.RefreshToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"}) return } // Refresh the token newToken, err := ctrl.OAuthService.RefreshAccessToken(accountType, token.RefreshToken) if err != nil { logger.Error("Failed to refresh token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh token"}) return } // Store the new token if err := ctrl.OAuthService.StoreOAuthToken(accountType, newToken); err != nil { logger.Error("Failed to store refreshed token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store refreshed token"}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Token refreshed successfully", "token_info": map[string]interface{}{ "token_type": newToken.TokenType, "expires_in": newToken.ExpiresIn, "scope": newToken.Scope, }, }) }