feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador

Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
  (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
  en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
  /callback/:proveedor intercambia el code (state autoverificable por HMAC,
  sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
  solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
  Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
  Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
  MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
  mensaje claro, no en silencio.

Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 10:25:13 -05:00
co-authored by Claude Sonnet 5
parent b2f6b518b6
commit 5ba41786d6
27 changed files with 1254 additions and 396 deletions
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
// OAuth a un tenant, para que el agente pueda enviar y leer correo en su
// nombre (tools enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
// dueño de la cuenta.
type UmindConexion struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
Email string `json:"email" gorm:"column:email;size:255"`
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindConexion) TableName() string { return "umind_conexiones" }
func CreateUmindConexion(c *UmindConexion) error {
return app.Http.Database.DB.Create(c).Error
}
func GetUmindConexionesByTenant(tenantID uint) ([]UmindConexion, error) {
var items []UmindConexion
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
var c UmindConexion
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
// GetUmindConexionActiva retorna la primera conexión activa del tenant —
// hoy se soporta una sola cuenta de correo conectada por tenant, no una
// bandeja por proveedor a la vez.
func GetUmindConexionActiva(tenantID uint) (*UmindConexion, error) {
var c UmindConexion
err := app.Http.Database.DB.Where("tenant_id = ? AND activo = ?", tenantID, true).First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
// DesactivarConexionesDelTenant se llama antes de crear una conexión nueva —
// hoy se soporta una sola cuenta de correo activa por tenant a la vez.
func DesactivarConexionesDelTenant(tenantID uint) error {
return app.Http.Database.DB.Model(&UmindConexion{}).
Where("tenant_id = ? AND activo = ?", tenantID, true).
Update("activo", false).Error
}
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindConexion(id uint) error {
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
}
+100
View File
@@ -82,9 +82,51 @@ func umindTools(tenantID uint) []agentTool {
},
})
}
if conexion, err := models.GetUmindConexionActiva(tenantID); err == nil && conexion != nil {
tools = append(tools, umindEmailTools()...)
}
return tools
}
// umindEmailTools son las tools de correo, disponibles solo cuando el
// tenant tiene una cuenta conectada (UmindConexion activa) — nombres
// genéricos porque al modelo no le importa si detrás hay Gmail u Outlook.
func umindEmailTools() []agentTool {
return []agentTool{
{
Type: "function",
Function: agentToolFunc{
Name: "enviar_correo",
Description: "Envía un correo electrónico desde la cuenta de correo conectada del negocio.",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"destinatario": {Type: "string", Description: "Email del destinatario"},
"asunto": {Type: "string", Description: "Asunto del correo"},
"cuerpo": {Type: "string", Description: "Cuerpo del correo en texto plano"},
},
Required: []string{"destinatario", "asunto", "cuerpo"},
},
},
},
{
Type: "function",
Function: agentToolFunc{
Name: "leer_bandeja",
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
},
Required: []string{"consulta"},
},
},
},
}
}
// executeUmindTool ejecuta buscar_conocimiento (RAG interno) o, si el nombre
// no matchea, busca una UmindHerramienta custom del tenant y hace el POST al
// webhook configurado. Devuelve el resultado ya serializado, en el mismo
@@ -111,6 +153,10 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return string(b)
}
if name == "enviar_correo" || name == "leer_bandeja" {
return executeUmindEmailTool(tenantID, name, args)
}
herramienta, err := models.GetUmindHerramientaByNombre(tenantID, name)
if err != nil {
return fmt.Sprintf(`{"error": "herramienta desconocida: %s"}`, name)
@@ -131,6 +177,60 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return resultado
}
// executeUmindEmailTool despacha enviar_correo/leer_bandeja a Gmail o
// Microsoft Graph según el proveedor de la conexión activa del tenant,
// refrescando el token primero si hace falta.
func executeUmindEmailTool(tenantID uint, name string, args map[string]interface{}) string {
conexion, err := models.GetUmindConexionActiva(tenantID)
if err != nil {
return `{"error": "no hay ninguna cuenta de correo conectada"}`
}
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
switch name {
case "enviar_correo":
destinatario, _ := args["destinatario"].(string)
asunto, _ := args["asunto"].(string)
cuerpo, _ := args["cuerpo"].(string)
if strings.TrimSpace(destinatario) == "" || strings.TrimSpace(cuerpo) == "" {
return `{"error": "destinatario y cuerpo son requeridos"}`
}
var envErr error
if conexion.Proveedor == UmindOAuthGoogle {
envErr = EnviarCorreoGoogle(conexion, destinatario, asunto, cuerpo)
} else {
envErr = EnviarCorreoMicrosoft(conexion, destinatario, asunto, cuerpo)
}
if envErr != nil {
log.Printf("[UMIND] Error enviando correo (tenant %d): %v", tenantID, envErr)
return `{"error": "no se pudo enviar el correo"}`
}
return `{"ok": true}`
case "leer_bandeja":
consulta, _ := args["consulta"].(string)
var resultados []CorreoResumen
var lecErr error
if conexion.Proveedor == UmindOAuthGoogle {
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
} else {
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
}
if lecErr != nil {
log.Printf("[UMIND] Error leyendo bandeja (tenant %d): %v", tenantID, lecErr)
return `{"error": "no se pudo leer la bandeja"}`
}
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
return string(b)
default:
return `{"error": "herramienta desconocida"}`
}
}
// ProcessWidgetMessage procesa un mensaje del widget de uMind y devuelve la
// respuesta del agente. Es el equivalente de ProcessAgentMessage pero
// multi-tenant y con un toolset acotado a RAG (sin herramientas internas).
+116
View File
@@ -0,0 +1,116 @@
package services
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// EnviarCorreoGoogle manda un correo de texto plano vía Gmail API en nombre
// de la cuenta conectada. Asume que conexion ya pasó por RefrescarSiVence.
func EnviarCorreoGoogle(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
mime := fmt.Sprintf("To: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s",
destinatario, asunto, cuerpo)
raw := base64.RawURLEncoding.EncodeToString([]byte(mime))
body, _ := json.Marshal(map[string]string{"raw": raw})
req, err := http.NewRequest(http.MethodPost, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
}
return nil
}
// LeerBandejaGoogle busca mensajes en Gmail (sintaxis de búsqueda de Gmail,
// ej: "from:cliente@ejemplo.com") y devuelve un resumen liviano de cada uno.
func LeerBandejaGoogle(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
listURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d",
url.QueryEscape(consulta), limite)
var lista struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
if err := gmailGetJSON(listURL, accessToken, &lista); err != nil {
return nil, err
}
resultados := make([]CorreoResumen, 0, len(lista.Messages))
for _, m := range lista.Messages {
detalleURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date", m.ID)
var msg struct {
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
if err := gmailGetJSON(detalleURL, accessToken, &msg); err != nil {
continue // un mensaje individual que falla no debe tirar abajo toda la búsqueda
}
r := CorreoResumen{Extracto: msg.Snippet}
for _, h := range msg.Payload.Headers {
switch h.Name {
case "From":
r.De = h.Value
case "Subject":
r.Asunto = h.Value
case "Date":
r.Fecha = h.Value
}
}
resultados = append(resultados, r)
}
return resultados, nil
}
func gmailGetJSON(url, accessToken string, out interface{}) error {
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 fmt.Errorf("no se pudo contactar Gmail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
}
return json.NewDecoder(resp.Body).Decode(out)
}
+109
View File
@@ -0,0 +1,109 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// EnviarCorreoMicrosoft manda un correo de texto plano vía Microsoft Graph
// (POST /me/sendMail) en nombre de la cuenta conectada.
func EnviarCorreoMicrosoft(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
payload := map[string]interface{}{
"message": map[string]interface{}{
"subject": asunto,
"body": map[string]string{"contentType": "Text", "content": cuerpo},
"toRecipients": []map[string]interface{}{
{"emailAddress": map[string]string{"address": destinatario}},
},
},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://graph.microsoft.com/v1.0/me/sendMail", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
}
return nil
}
// LeerBandejaMicrosoft busca mensajes en la bandeja vía Microsoft Graph
// ($search sobre asunto/cuerpo/remitente) y devuelve un resumen liviano.
func LeerBandejaMicrosoft(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
q := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?$search=%s&$top=%d&$select=from,subject,receivedDateTime,bodyPreview",
url.QueryEscape(`"`+consulta+`"`), limite)
req, err := http.NewRequest(http.MethodGet, q, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
// $search requiere este header ("eventual consistency") en Microsoft Graph.
req.Header.Set("ConsistencyLevel", "eventual")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
}
var out struct {
Value []struct {
From struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
} `json:"from"`
Subject string `json:"subject"`
ReceivedDateTime string `json:"receivedDateTime"`
BodyPreview string `json:"bodyPreview"`
} `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
resultados := make([]CorreoResumen, 0, len(out.Value))
for _, m := range out.Value {
resultados = append(resultados, CorreoResumen{
De: m.From.EmailAddress.Address,
Asunto: m.Subject,
Fecha: m.ReceivedDateTime,
Extracto: m.BodyPreview,
})
}
return resultados, nil
}
+266
View File
@@ -0,0 +1,266 @@
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 (tenantID + 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(tenantID uint) (string, error) {
nonce := make([]byte, 8)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
payload := fmt.Sprintf("%d.%s", tenantID, 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")
}
tenantID, err := strconv.ParseUint(partes[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("tenant_id inválido en state: %w", err)
}
return uint(tenantID), 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, tenantID uint) (string, error) {
cfg, err := oauth2ConfigPara(proveedor)
if err != nil {
return "", err
}
state, err := firmarState(tenantID)
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 tenant (una cuenta de correo a la vez).
func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) {
tenantID, 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.DesactivarConexionesDelTenant(tenantID); err != nil {
log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del tenant %d: %v", tenantID, err)
}
conexion := &models.UmindConexion{
TenantID: tenantID,
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
}
+34
View File
@@ -0,0 +1,34 @@
package services
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/config"
)
func TestFirmarYVerificarState(t *testing.T) {
app.Http = &config.AppConfig{Server: config.ServerConfig{Key: "clave-de-prueba-no-real"}}
state, err := firmarState(42)
if err != nil {
t.Fatalf("firmarState: %v", err)
}
tenantID, err := verificarState(state)
if err != nil {
t.Fatalf("verificarState de un state válido falló: %v", err)
}
if tenantID != 42 {
t.Errorf("tenantID = %d, esperaba 42", tenantID)
}
if _, err := verificarState(state + "x"); err == nil {
t.Error("un state alterado fue aceptado")
}
if _, err := verificarState("formato.invalido"); err == nil {
t.Error("un state con formato inválido fue aceptado")
}
if _, err := verificarState("noesnumero.aabbcc.deadbeef"); err == nil {
t.Error("un tenant_id no numérico fue aceptado")
}
}