package services import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "golang.org/x/oauth2/microsoft" ) const ( UmindOAuthGoogle = "google" UmindOAuthMicrosoft = "microsoft" ) // CorreoResumen es el formato común en el que enviar_correo/leer_bandeja // devuelven un mensaje al agente, sin importar el proveedor real detrás. type CorreoResumen struct { De string `json:"de"` Asunto string `json:"asunto"` Fecha string `json:"fecha"` Extracto string `json:"extracto"` } var umindOAuthHTTPClient = &http.Client{Timeout: 15 * time.Second} // firmarState / verificarState arman el parámetro state del flujo OAuth // autoverificable (agenteID + nonce + HMAC con APP_KEY) — evita necesitar una // tabla de "estados pendientes": si la firma es válida, el state no fue // alterado desde que lo generamos nosotros. func firmarState(agenteID uint) (string, error) { nonce := make([]byte, 8) if _, err := rand.Read(nonce); err != nil { return "", err } payload := fmt.Sprintf("%d.%s", agenteID, hex.EncodeToString(nonce)) mac := hmac.New(sha256.New, []byte(app.Http.Server.Key)) mac.Write([]byte(payload)) return payload + "." + hex.EncodeToString(mac.Sum(nil)), nil } func verificarState(state string) (uint, error) { partes := strings.Split(state, ".") if len(partes) != 3 { return 0, fmt.Errorf("formato de state inválido") } payload := partes[0] + "." + partes[1] mac := hmac.New(sha256.New, []byte(app.Http.Server.Key)) mac.Write([]byte(payload)) esperada := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(esperada), []byte(partes[2])) { return 0, fmt.Errorf("firma de state inválida") } agenteID, err := strconv.ParseUint(partes[0], 10, 64) if err != nil { return 0, fmt.Errorf("agente_id inválido en state: %w", err) } return uint(agenteID), nil } func redirectURLOAuth(proveedor string) string { return strings.TrimRight(app.Http.Server.Url, "/") + "/app/umind/conexiones/callback/" + proveedor } func oauth2ConfigPara(proveedor string) (*oauth2.Config, error) { switch proveedor { case UmindOAuthGoogle: if app.Http.OAuth.GoogleClientID == "" || app.Http.OAuth.GoogleClientSecret == "" { return nil, fmt.Errorf("Google OAuth no está configurado en el servidor (GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET)") } return &oauth2.Config{ ClientID: app.Http.OAuth.GoogleClientID, ClientSecret: app.Http.OAuth.GoogleClientSecret, RedirectURL: redirectURLOAuth(proveedor), Scopes: []string{ "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/userinfo.email", }, Endpoint: google.Endpoint, }, nil case UmindOAuthMicrosoft: if app.Http.OAuth.MSClientID == "" || app.Http.OAuth.MSClientSecret == "" { return nil, fmt.Errorf("Microsoft OAuth no está configurado en el servidor (MS_OAUTH_CLIENT_ID/MS_OAUTH_CLIENT_SECRET)") } return &oauth2.Config{ ClientID: app.Http.OAuth.MSClientID, ClientSecret: app.Http.OAuth.MSClientSecret, RedirectURL: redirectURLOAuth(proveedor), Scopes: []string{ "offline_access", "openid", "email", "https://graph.microsoft.com/Mail.Send", "https://graph.microsoft.com/Mail.Read", }, Endpoint: microsoft.AzureADEndpoint("common"), }, nil default: return nil, fmt.Errorf("proveedor desconocido: %s", proveedor) } } // IniciarConexionOAuth arma la URL de autorización a la que hay que // redirigir al staff. prompt=consent en Google fuerza a que siempre vuelva // un refresh_token (si no, Google solo lo manda la primera vez que el // usuario autoriza la app, nunca más). func IniciarConexionOAuth(proveedor string, agenteID uint) (string, error) { cfg, err := oauth2ConfigPara(proveedor) if err != nil { return "", err } state, err := firmarState(agenteID) if err != nil { return "", err } opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} if proveedor == UmindOAuthGoogle { opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent")) } return cfg.AuthCodeURL(state, opts...), nil } // CompletarConexionOAuth intercambia el code por tokens, identifica la // cuenta conectada y guarda la conexión cifrada. Reemplaza cualquier // conexión previa activa del agente (una cuenta de correo a la vez). func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) { agenteID, err := verificarState(state) if err != nil { return nil, fmt.Errorf("state inválido: %w", err) } cfg, err := oauth2ConfigPara(proveedor) if err != nil { return nil, err } tok, err := cfg.Exchange(context.Background(), code) if err != nil { return nil, fmt.Errorf("no se pudo intercambiar el código de autorización: %w", err) } if tok.RefreshToken == "" { return nil, fmt.Errorf("el proveedor no devolvió un refresh_token — revocá el acceso de esta app en tu cuenta y volvé a conectar") } email, err := obtenerEmailDeCuenta(proveedor, tok.AccessToken) if err != nil { log.Printf("[UMIND_OAUTH] no se pudo obtener el email de la cuenta conectada (%s): %v", proveedor, err) } accessEnc, err := CifrarSecretoUmind(tok.AccessToken) if err != nil { return nil, err } refreshEnc, err := CifrarSecretoUmind(tok.RefreshToken) if err != nil { return nil, err } if err := models.DesactivarConexionesDelAgente(agenteID); err != nil { log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del agente %d: %v", agenteID, err) } conexion := &models.UmindConexion{ AgenteID: agenteID, Proveedor: proveedor, Email: email, AccessTokenEnc: accessEnc, RefreshTokenEnc: refreshEnc, ExpiraEn: tok.Expiry, Scopes: strings.Join(cfg.Scopes, " "), Activo: true, } if err := models.CreateUmindConexion(conexion); err != nil { return nil, err } return conexion, nil } func obtenerEmailDeCuenta(proveedor, accessToken string) (string, error) { var url string switch proveedor { case UmindOAuthGoogle: url = "https://www.googleapis.com/oauth2/v2/userinfo" case UmindOAuthMicrosoft: url = "https://graph.microsoft.com/v1.0/me" default: return "", fmt.Errorf("proveedor desconocido: %s", proveedor) } req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := umindOAuthHTTPClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { Email string `json:"email"` // Google Mail string `json:"mail"` // Microsoft UserPrincipalName string `json:"userPrincipalName"` // Microsoft, fallback si "mail" viene vacío } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } if out.Email != "" { return out.Email, nil } if out.Mail != "" { return out.Mail, nil } return out.UserPrincipalName, nil } // RefrescarSiVence renueva el access token si está vencido o a menos de 2 // minutos de vencer, y persiste el nuevo valor cifrado. Se llama justo antes // de usar la conexión (enviar/leer correo), no por un cron aparte — ver nota // de alcance en el plan: si en la práctica hace falta refresco proactivo, se // agrega por pkg/services/cron_service.go sin tocar esta función. func RefrescarSiVence(conexion *models.UmindConexion) error { if time.Now().Add(2 * time.Minute).Before(conexion.ExpiraEn) { return nil } cfg, err := oauth2ConfigPara(conexion.Proveedor) if err != nil { return err } refreshToken, err := DescifrarSecretoUmind(conexion.RefreshTokenEnc) if err != nil { return fmt.Errorf("no se pudo descifrar el refresh_token: %w", err) } nuevo, err := cfg.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}).Token() if err != nil { return fmt.Errorf("no se pudo refrescar el token: %w", err) } accessEnc, err := CifrarSecretoUmind(nuevo.AccessToken) if err != nil { return err } updates := map[string]interface{}{"access_token_enc": accessEnc, "expira_en": nuevo.Expiry} if nuevo.RefreshToken != "" && nuevo.RefreshToken != refreshToken { if refreshEnc, err := CifrarSecretoUmind(nuevo.RefreshToken); err == nil { updates["refresh_token_enc"] = refreshEnc conexion.RefreshTokenEnc = refreshEnc } } if err := models.UpdateUmindConexion(conexion.ID, updates); err != nil { log.Printf("[UMIND_OAUTH] no se pudo persistir el refresh del token (conexión %d): %v", conexion.ID, err) } conexion.AccessTokenEnc = accessEnc conexion.ExpiraEn = nuevo.Expiry return nil }