Login con Google y cuentas existentes¶
Análisis de por qué un cliente que ya tiene cuenta con contraseña no puede entrar con Google, y qué haría falta para resolverlo. Corresponde a la HU-05.
Resumen para quien decide¶
El módulo @medusajs/medusa/auth-google ya está instalado y configurado. No
es una mejora pendiente: está en medusa-config.ts:77, versión 2.15.5, con sus
tres opciones (clientId, clientSecret, callbackUrl) exactamente como indica
la documentación oficial.
Instalarlo no resuelve el problema, porque el problema no está ahí. El módulo hace bien su trabajo: autentica contra Google y crea la identidad. Lo que falta es el paso que vincula esa identidad a un cliente que ya existe, y ese paso no existe ni en nuestra implementación ni en la de referencia de Medusa.
La premisa de la HU-05 es incorrecta. La historia dice que Google "reemplaza" la credencial de email/password. No hay ningún reemplazo: la contraseña queda intacta y sigue funcionando. Lo que ocurre es que Google nunca logra vincularse a la cuenta que ya existe.
Actualización del 2026-09-08 · el esquema no era el límite. Al revisar las
tablas se corrigió un supuesto: auth_identity acepta varias credenciales
—emailpass y google juntas—; es literalmente para eso que existen dos tablas.
Lo que no lo usa es el flujo automático de los proveedores, pero la API pública
del módulo sí deja hacerlo desde la aplicación. Eso reabre la opción que se había
descartado y, de paso, resuelve la dirección Google → contraseña con el flujo de
recuperación de Medusa, sin código propio. La recomendación pasa a ser
consolidar las dos credenciales bajo una sola identidad (opción 2).
Qué se observó¶
Reproducido el 2026-09-07 sobre la base de desarrollo, con dos cuentas reales.
Alcance de lo medido. Se probó la dirección contraseña primero, después Google (modos A y B). La dirección inversa —Google primero, después contraseña— está pendiente de medición; lo que este documento dice sobre ella es análisis del código, no observación.
Modo de falla A — el cliente queda afuera¶
Cliente mardanval94@gmail.com, registrado con contraseña a las 15:57, sin
identidad de Google. Al intentar entrar con Google:
GET /auth/customer/google/callback 200 Google autentica, se crea la identidad
POST /store/customers 422 "Customer with this email already has an account"
GET /py/account?error=Customer+with+this+email+already+has+an+account
Resultado en la base:
| Clientes con ese correo | 1 (el de la contraseña, intacto) |
| Identidad de Google creada | 111985271825092679490 |
| Cliente al que apunta | ninguno |
El cliente no puede entrar con Google, y queda una identidad huérfana. Su
contraseña sigue funcionando: el mismo log muestra un POST /auth/customer/emailpass
con 200 posterior.
Modo de falla B — cliente duplicado¶
Cliente ivan@compulandia.com.py, creado el 2025-09-23 como invitado
(has_account = false) a partir de un pedido. Al entrar con Google el 2026-08-25
se creó un segundo cliente con el mismo correo:
| id | creado | identidades | pedidos |
|---|---|---|---|
cus_01K5VYXW… |
2025-09-23 | ninguna | 2 |
cus_01M0WM2Q… |
2026-08-25 | 0 |
El cliente que tiene los pedidos no tiene ninguna forma de entrar, y el que entra con Google no ve su historial.
La variable que separa los dos modos¶
has_account = true → Medusa rechaza con 422 → modo A
has_account = false → Medusa no protege → modo B
Medusa sólo protege contra el duplicado cuando el cliente tiene cuenta. Los clientes invitados —los que compraron sin registrarse— quedan expuestos.
Modelo de datos en la base¶
Esta sección se agregó el 2026-09-08, al revisar el esquema y el código del módulo
@medusajs/auth 2.15.5 instalado. Corrige un supuesto que veníamos arrastrando.
Las tablas¶
El módulo de autenticación tiene cinco tablas. Dos importan acá:
auth_identity — la persona que puede entrar. No guarda correo, ni
contraseña, ni proveedor, ni cliente:
| Columna | Tipo | Para qué |
|---|---|---|
id |
text | authid_… |
app_metadata |
jsonb | acá adentro vive customer_id |
created_at / updated_at / deleted_at |
timestamptz |
provider_identity — cada credencial de esa persona:
| Columna | Tipo | Para qué |
|---|---|---|
id |
text | |
entity_id |
text | la clave con la que el proveedor la reconoce |
provider |
text | emailpass o google |
auth_identity_id |
text | FK a auth_identity, ON DELETE CASCADE |
provider_metadata |
jsonb | el hash scrypt de la contraseña, en emailpass |
user_metadata |
jsonb | el perfil que devolvió Google: email, name, picture |
Con un índice que manda: IDX_provider_identity_provider_entity_id, único sobre
(entity_id, provider).
Las otras tres —auth_verification_token, auth_mfa_factor,
auth_mfa_recovery_code— cuelgan también de auth_identity.
Cómo se ata todo¶
customer (tabla del módulo customer, otro módulo)
▲
│ app_metadata->>'customer_id'
│ jsonb · sin FK · sin índice · sin unicidad
│
auth_identity ──────1───────N──────▶ provider_identity
id, app_metadata (entity_id, provider) ÚNICO
│ provider_metadata → hash de contraseña
│ user_metadata → correo de Google
└───1───N──▶ auth_mfa_factor · auth_verification_token
El vínculo con el cliente no es una foreign key: es una clave dentro de un
jsonb. Tiene que ser así porque customer pertenece a otro módulo, y Medusa no
cruza módulos con FK. De ahí salen la mitad de los problemas de esta HU: nadie
valida esa referencia, nadie impide que se repita y nadie avisa cuando apunta a un
cliente que ya no existe.
Con qué clave entra cada proveedor¶
| Proveedor | entity_id |
Quién lo fija | Dónde queda el correo |
|---|---|---|---|
emailpass |
el correo | el usuario al registrarse | es el propio entity_id |
google |
el sub numérico de Google |
Google (google.js:96) |
en user_metadata.email, copiado del id_token (google.js:100) |
Son dos espacios de claves disjuntos. mvaliente@compulandia.com.py y
105901768852128534026 son la misma persona, y no hay ninguna consulta que lo
deduzca: el único puente es un campo dentro de un jsonb sin índice.
El supuesto que hay que corregir¶
Veníamos suponiendo que el esquema soporta un solo proveedor por identidad, y
que por eso las credenciales quedan separadas. No es así. La relación
auth_identity → provider_identity es uno a muchos: para eso existen dos
tablas en vez de una. El esquema fue diseñado exactamente para el caso de esta
HU —una persona, varias formas de entrar—.
Lo que no lo usa es el flujo automático. Cuando un proveedor no encuentra su
identidad, llama a create, y ese create arma siempre una auth_identity
nueva con un único proveedor adentro (auth-module.js:531); no recibe ni
acepta un auth_identity_id. Google no tiene forma de saber que ese correo ya
tenía una identidad, así que crea la suya.
Pero la API pública del módulo sí lo acepta, y lo documenta:
// @medusajs/types · auth/common/auth-identity.d.ts:107
export type CreateProviderIdentityDTO = {
provider: string
entity_id: string
/** The auth identity linked to the provider identity.
Needs to be specified if creating a new provider identity directly. */
auth_identity_id?: string
...
}
O sea: la unión bajo una sola identidad no la hace el proveedor, pero sí la
puede hacer la aplicación, con createProviderIdentities(), sin tocar el módulo
ni escribir SQL. Esto cambia la evaluación de la opción C del 2026-09-07, que se
había descartado por "falta de punto de extensión". El punto existe; lo que no
existe es el automatismo.
Una salvedad: mover una credencial de una identidad a otra no se puede.
UpdateProviderIdentityDTO (auth-identity.d.ts:122) sólo deja cambiar
entity_id, provider_metadata y user_metadata, nunca auth_identity_id. Para
consolidar hay que borrar y volver a crear la fila, conservando su
provider_metadata —es decir, conservando la contraseña—.
Foto de la base al 2026-09-08¶
auth_identity |
21 |
provider_identity |
17 (12 emailpass, 5 google) |
| Identidades con más de un proveedor | 0 |
Identidades sin customer_id |
9 |
| Identidades sin ningún proveedor (restos de borrados) | 4 |
| Identidades apuntando a un cliente que ya no existe | 1 |
| Clientes | 12 |
| Clientes con dos identidades apuntando a ellos | 1 (mvaliente@compulandia.com.py) |
| Factores de MFA configurados | 0 |
Casi la mitad de las identidades no lleva a ningún cliente. No todas son fallas de esta HU —hay registros abandonados a mitad de camino—, pero el número da la dimensión del descuido: nadie limpia ni valida esa tabla.
Cuatro consecuencias que definen el problema¶
- Nadie puede cruzar los dos proveedores por sí solo. Las claves son
disjuntas y el correo de Google está enterrado en un
jsonb. app_metadatano tiene unicidad. Nada impide dos identidades con el mismocustomer_id—así funciona hoy la cuenta sana— y nada detecta que una apunte a un cliente borrado. Ya hay una en ese estado.- Borrar la cuenta desvincula una sola identidad.
removeCustomerAccountWorkflowtomaauthIdentities[0](remove-customer-account.js:64). Con dos identidades, una queda con elcustomer_idde un cliente que ya no existe. - El MFA cuelga de
auth_identity. Con dos identidades separadas, activar un segundo factor protege una vía de entrada y deja la otra abierta. Hoy no hay factores configurados, pero es la trampa que deja el patrón de dos identidades.
Los puntos 3 y 4 son el argumento nuevo: el patrón de dos identidades funciona para entrar, pero se rompe en el borrado y en el MFA.
Tres detalles del código que condicionan la solución¶
El correo de recuperación no se puede pedir para una identidad que no existe.
generateResetPasswordTokenWorkflow busca la provider_identity por
(entity_id, provider) y aborta si no está (generate-reset-password-token.js:50).
Peor: la ruta corre el workflow con throwOnError: false y responde 201 igual,
para no delatar qué correos están registrados. Un cliente que entró sólo con Google
y pide recuperar su contraseña ve "listo, revisá tu correo" y no le llega nada,
sin ningún error en ninguna parte.
Una credencial emailpass sin contraseña no deja entrar a nadie.
authenticate sólo valida si provider_metadata.password es un string
(emailpass.js:83); si falta, responde "Invalid email or password". Esto habilita
una técnica limpia para la dirección 2, más abajo.
Una identidad sin app_metadata es "reclamable". emailpass.register
(emailpass.js:116) considera que si la identidad no tiene actor asignado,
cualquiera que se registre con ese correo se queda con ella y le pisa la
contraseña. Nuestras cuatro identidades emailpass huérfanas están en ese estado.
Cómo funciona hoy¶
1. El cliente pulsa "Continuar con Google"
google-login-button/index.tsx:41 fetch(`${backendUrl}/auth/customer/google`)
2. El módulo auth-google devuelve la URL de Google; el navegador redirige
3. Google autentica y vuelve a
/py/auth/google/callback?code=…&state=…
4. El callback valida el código contra Medusa
callback/page.tsx:51 sdk.auth.callback("customer", "google", queryParams)
Medusa crea la identidad y devuelve un token
5. El callback decide si crear un cliente
callback/page.tsx:64 const shouldCreateCustomer =
!decodedToken?.actor_id || decodedToken.actor_id === ""
6. Si decide que sí, lo crea sin averiguar si ya existe uno con ese correo
customer.ts:139 createGoogleCustomer(email, firstName, lastName)
→ POST /store/customers
El hueco está entre el paso 5 y el 6: nadie busca un cliente existente por correo antes de crear uno nuevo.
Qué aporta el módulo, y qué no¶
El módulo cubre el intercambio con Google y el registro de la identidad. Lo que queda del lado de la aplicación es todo lo relativo al cliente.
| Responsabilidad | ¿La cubre el módulo? |
|---|---|
| Redirigir a Google y validar el código | Sí |
| Crear la identidad y emitir el token | Sí |
Devolver actor_id si la identidad ya está vinculada |
Sí |
| Buscar un cliente existente por correo | No |
| Vincular la identidad a ese cliente | No |
| Manejar el rechazo por correo duplicado | No |
Las tres últimas quedan del lado del storefront, y ninguna está implementada.
La implementación de referencia tiene el mismo hueco¶
La guía oficial Third-Party or Social Login in Storefront propone exactamente esto:
const shouldCreateCustomer = decodedToken.actor_id === ""
if (shouldCreateCustomer) {
await createCustomer(decodedToken.user_metadata.email)
await refreshToken()
}
No contempla que ya exista un cliente con ese correo. Nuestra implementación no se desvió de la referencia: copió su limitación. Alinearse más con la guía no mejora nada en este punto.
Diferencias reales con la referencia¶
Dos, ambas menores frente al hueco principal, pero conviene registrarlas.
El botón no contempla el camino rápido. La guía usa
sdk.auth.login("customer", "google", {}), que puede devolver una URL o
directamente un token cuando la identidad ya está vinculada. Nuestro botón usa
fetch crudo y sólo maneja data.location; si llegara un token, cae al else y
sólo escribe un error en consola (google-login-button/index.tsx:47).
El token se decodifica a mano. La guía usa react-jwt; nosotros hacemos
atob sobre la carga útil (callback/page.tsx:13). Funciona, pero sin ninguna
validación de forma.
Qué haría falta¶
Un paso de vinculación entre el 5 y el 6: si ya existe un cliente con el correo que devuelve Google, atar la identidad a ese cliente en lugar de crear uno nuevo.
Eso resuelve los dos modos de falla con la misma lógica.
Necesita el backend. La API de tienda no permite buscar clientes por correo
—sería una filtración de datos: cualquiera podría averiguar si una dirección está
registrada— así que hace falta una ruta propia en Medusa que reciba la identidad
ya autenticada, busque el cliente y escriba el vínculo. El patrón existe:
src/api/middlewares.ts ya registra rutas propias.
Comportamiento buscado¶
Definido con el equipo el 2026-09-07. Son dos direcciones, y se resuelven distinto.
Cómo se vincula hoy en este proyecto. Ninguna auth_identity de la base tiene
más de un proveedor. La cuenta sana (mvaliente@compulandia.com.py) funciona
porque tiene dos auth_identity separadas que apuntan al mismo cliente por
app_metadata.customer_id.
Las tablas siguientes describen ese estado observado. No son la forma recomendada: la revisión del 2026-09-08 mostró que el esquema admite las dos credenciales bajo una sola identidad y que eso arregla además el borrado de cuenta y el MFA. Ver Modelo de datos en la base y la opción 2.
Dirección 1 · contraseña primero, después Google¶
Hoy, al entrar con Google:
| Tabla | Fila | Estado |
|---|---|---|
customer |
cus_A · mardanval94@gmail.com |
intacto |
auth_identity |
authid_1 → customer_id = cus_A |
vinculada |
provider_identity |
emailpass · mardanval94@gmail.com → authid_1 |
ok |
auth_identity |
authid_2 → customer_id = vacío |
huérfana |
provider_identity |
google · 111985… → authid_2 |
sin cliente |
Buscado: una sola línea de diferencia.
| Tabla | Fila | Estado |
|---|---|---|
auth_identity |
authid_2 → customer_id = cus_A |
vinculada |
Un cliente, dos identidades, las dos apuntando a él. Es exactamente la forma que ya tiene la cuenta sana.
Dirección 2 · Google primero, después contraseña¶
El equipo definió que este camino no pasa por el registro sino por recuperar contraseña: el cliente pide restablecerla y esa credencial queda asignada a la cuenta con la que entró por Google.
Estado de partida:
| Tabla | Fila |
|---|---|
customer |
cus_B · mardanval94@gmail.com |
auth_identity |
authid_3 → customer_id = cus_B |
provider_identity |
google · 111985… → authid_3 |
Buscado:
| Tabla | Fila |
|---|---|
provider_identity |
emailpass · mardanval94@gmail.com → authid_4 |
auth_identity |
authid_4 → customer_id = cus_B |
Obstáculo detectado. Recuperar contraseña, por sí solo, no alcanza. El
método update del proveedor emailpass llama a getProviderMetadata_(entity_id)
para recuperar la identidad y actualizarle el hash
(auth-emailpass/dist/services/emailpass.js:36). Si el cliente entró sólo con
Google, esa identidad no existe y no hay nada que actualizar: el flujo de
recuperación actualiza, no crea.
Hacen falta dos piezas, no una:
- Crear la credencial de
emailpasspara ese correo si todavía no existe. - Vincularla al cliente con el que entró por Google, igual que en la dirección 1.
Es la misma operación de vinculación de la dirección 1, más un paso de creación. Conviene escribir la vinculación una sola vez y usarla desde los dos caminos.
Actualización del 2026-09-08. La segunda pieza deja de ser un problema si la
credencial se crea bajo la misma auth_identity con la que entró por Google:
ahí no hay nada que vincular, porque la identidad ya tiene el customer_id. Y la
primera se resuelve creando la fila sin contraseña, que no autentica a nadie
(emailpass.js:83) y sí habilita el correo de recuperación oficial
(generate-reset-password-token.js:50). Es lo que describe la opción 2.
Propuestas de solución¶
Revisadas el 2026-09-08 con el modelo de datos a la vista. Son cuatro, y la diferencia entre las tres primeras es qué queda escrito en la base, no qué ve el usuario: en las tres el cliente entra a su cuenta por las dos vías.
La asimetría que define el diseño¶
Las dos direcciones no se resuelven igual, y no es una cuestión de comodidad sino de seguridad.
| Dirección | Quién probó ser dueño del correo | Qué hace falta |
|---|---|---|
| Contraseña → Google | Google, con un id_token firmado y email_verified |
Vincular, sin más |
| Google → contraseña | nadie todavía | Probar la propiedad del correo antes de vincular |
Esto descarta la salida más obvia para la dirección 2. Si el registro con contraseña, al chocar con el 422, simplemente vinculara la identidad al cliente existente, cualquiera podría registrarse con el correo de otro, elegir una contraseña y quedarse con su cuenta. Sería un agujero de apropiación de cuenta.
Por eso la dirección 2 tiene que pasar por el correo de recuperación: ese correo, y sólo ese, prueba que quien pide la contraseña recibe mensajes en esa casilla. Es la misma garantía que aporta Google en la dirección 1.
Opción 1 — Duplicar el vínculo: dos identidades, un cliente¶
Era la opción A del 2026-09-07.
Una ruta propia en Medusa, POST /store/vincular-identidad, que no recibe
correo —lo lee de la identidad ya autenticada— busca el cliente por ese correo y
le escribe app_metadata.customer_id a la identidad nueva:
Authorization: Bearer <token emitido tras validar con Google>
(sin cuerpo)
1. lee auth_identity_id del token (generate-jwt-token.js:40)
2. lee user_metadata.email de esa identidad
3. busca un customer con ese correo
4. si existe → escribe app_metadata.customer_id
si no → 404, y el storefront crea el cliente como hoy
Cómo queda la base:
auth_identity |
proveedor | app_metadata.customer_id |
|---|---|---|
authid_1 |
emailpass · el correo |
cus_A |
authid_2 |
google · el sub |
cus_A ← lo que se agrega |
A favor. Es el cambio más chico posible: una escritura en un jsonb, con
updateAuthIdentities. Reproduce el patrón que ya funciona en producción
(mvaliente@compulandia.com.py). No borra ni recrea nada, así que ningún token
vigente se invalida.
En contra. Deja el vínculo duplicado, y con él las dos trampas del modelo:
al borrar la cuenta se desvincula una sola identidad
(remove-customer-account.js:64) y el MFA, si algún día se activa, protege una
vía y deja la otra abierta. Además no resuelve la dirección 2 por sí sola: sigue
faltando crear la credencial emailpass que no existe.
Opción 2 — Consolidar: una identidad, dos credenciales¶
Era la opción C, descartada el 2026-09-07 por falta de punto de extensión. El
punto existe (auth-identity.d.ts:107), así que se reevalúa. Recomendada.**
La misma ruta de la opción 1 y los mismos puntos de llamada. Cambia qué
escribe: en vez de duplicar el customer_id, mueve la credencial vieja bajo la
identidad con la que el cliente se acaba de autenticar.
1. lee auth_identity_id y user_metadata.email del token
2. busca un customer con ese correo → si no hay, 404 y se crea como hoy
3. busca la otra auth_identity de esa persona (la que ya tiene ese customer_id)
4. re-crea sus provider_identity bajo la identidad autenticada,
conservando provider_metadata (o sea, la misma contraseña)
→ createProviderIdentities({ provider, entity_id, auth_identity_id,
provider_metadata, user_metadata })
5. escribe customer_id en la identidad autenticada
6. borra la identidad vieja, que quedó vacía
→ deleteAuthIdentities([...])
Cómo queda la base:
auth_identity |
proveedores | app_metadata.customer_id |
|---|---|---|
authid_2 |
emailpass · el correo + google · el sub |
cus_A |
Un cliente, una identidad, dos formas de entrar. Es la forma para la que el esquema fue diseñado.
Por qué se consolida hacia la identidad nueva y no al revés. El token que el
cliente tiene en la mano en ese momento apunta a la identidad de Google recién
creada. Si borráramos ésa, ese token quedaría apuntando a una fila que no existe y
habría que rebotar al usuario por Google otra vez. Consolidando en la otra
dirección, el token sigue válido y sólo hace falta el refresh que el callback ya
hace hoy.
Efecto colateral aceptado. Las sesiones abiertas con el token viejo de
emailpass dejan de resolver: quien las tenga vuelve a entrar. Es la misma
consecuencia que un cambio de contraseña.
Lo que sale casi gratis: la dirección 2. Con la identidad ya consolidada, para
que un cliente de Google pueda ponerse contraseña alcanza con crear su
provider_identity de emailpass sin provider_metadata.password, colgando
de la misma identidad. Desde ahí:
- nadie puede entrar con esa credencial mientras no tenga hash (
emailpass.js:83); - el flujo estándar de "olvidé mi contraseña" ya la encuentra
(
generate-reset-password-token.js:50deja de abortar) y manda el correo; emailpass.update(emailpass.js:36) le escribe el hash cuando el cliente elige la contraseña.
Es decir: no hay que escribir un flujo de recuperación propio ni replicar el
hasheo scrypt de Medusa. Se crea el hueco y el mecanismo oficial lo llena. Eso
saca del camino la pieza que el 2026-09-07 quedó marcada como la más incierta.
En contra. Escribe directamente sobre las tablas del módulo de auth —con su API pública, no con SQL— y hace tres operaciones donde la opción 1 hace una. Si falla a la mitad, hay que dejarlo dentro de un workflow o una transacción para no partir la credencial. Es más código y más pruebas que la opción 1.
Opción 3 — Proveedor de auth propio que extiende el de Google¶
Era la opción B, precisada.
Medusa registra los proveedores por resolve + id en medusa-config.ts:77, así
que se puede publicar uno propio en src/modules/auth-google-vinculado que
extienda GoogleAuthService y sobrescriba validateCallback para vincular antes
de devolver la identidad. El paquete no declara exports restrictivos, así que la
clase se puede importar y heredar.
A favor. El storefront no cambia una línea, y cualquier cliente futuro —una app móvil, el admin— hereda el arreglo.
En contra. Hereda de una ruta interna (@medusajs/auth-google/dist/services/google),
no de una API pública. Es la clase de acoplamiento que ya nos costó caro con
deleteLineItem, donde el SDK cambió una firma que nunca tocamos. Y no aporta
nada que la ruta de las opciones 1 o 2 no dé, porque el punto donde falta la
decisión —crear o vincular— está igual de accesible desde afuera.
Opción 4 — Contención: no vincular, pero avisar bien¶
El mínimo absoluto: dejar el comportamiento como está y decirle al cliente qué
pasó. Hoy, en el modo A, termina en
/py/account?error=Customer+with+this+email+already+has+an+account, un mensaje en
inglés que no explica nada. Reemplazarlo por "ya tenés una cuenta con este correo,
entrá con tu contraseña" es una tarde de trabajo.
No resuelve la HU —el cliente sigue sin poder entrar con Google, y el invitado duplicado sigue duplicándose— pero sirve si hay que soltar algo antes del release grande.
Comparación¶
| Opción 1 · duplicar | Opción 2 · consolidar | Opción 3 · proveedor propio | Opción 4 · avisar | |
|---|---|---|---|---|
| El cliente entra por las dos vías | Sí | Sí | Sí | No |
| Filas que quedan por persona | 2 identidades | 1 identidad | 2 identidades | — |
| Borrado de cuenta correcto | No | Sí | No | — |
| MFA cubre las dos vías | No | Sí | No | — |
| Resuelve la dirección 2 | Falta la pieza incierta | Sí, con el flujo oficial | Falta lo mismo | No |
| Toca el storefront | Sí | Sí | No | Sí |
| Acoplamiento a Medusa | API pública | API pública | ruta interna | ninguno |
| Tamaño | chico | mediano | mediano | mínimo |
Recomendación¶
Opción 2. La ruta, los puntos de llamada y las validaciones de seguridad son los mismos que ya se diseñaron para la opción 1; lo único que cambia es que en vez de duplicar el vínculo lo unifica. A cambio de unas veinte líneas más se llevan tres problemas por delante: el borrado de cuenta, el MFA a futuro y —sobre todo— la dirección 2, que con la identidad consolidada la resuelve el flujo de recuperación de Medusa sin código propio.
Si hiciera falta entregar algo antes del release grande, la opción 4 es independiente y no estorba: el mensaje sigue sirviendo para los casos que la vinculación no cubra.
Trabajo estimado¶
Para la opción 2, que es la recomendada.
| Pieza | Dónde | Tamaño |
|---|---|---|
Ruta POST /store/vincular-identidad, con la consolidación adentro |
backend, src/api/store/… |
media |
| Llamarla en el callback de Google, antes de crear el cliente | storefront, callback/page.tsx:64 |
chica |
Crear la credencial emailpass vacía para el cliente que llegó por Google |
backend, misma ruta u otra del perfil | chica |
| Mensaje de error entendible cuando no se puede vincular | storefront | chica |
| Script de reparación de los que ya están rotos | backend, aparte | media |
La pieza que el 2026-09-07 figuraba como la más incierta —"recuperación que cree la identidad si falta"— desaparece: con la identidad consolidada y la credencial vacía creada, el flujo de recuperación de Medusa funciona sin modificaciones.
Sigue pendiente de medir la dirección 2: Google primero, contraseña después.
La cuenta mardanval94@gmail.com quedó borrada a propósito para probarla desde
cero; su identidad de Google sigue en la base, huérfana, desde el 2026-09-07.
Requisitos verificables¶
| # | Requisito | Cómo se verifica |
|---|---|---|
| R1 | Un cliente con cuenta y contraseña que entra con Google accede a su misma cuenta | Un solo cliente con ese correo; la identidad de Google apunta a él |
| R2 | Un cliente invitado que entra con Google accede a la cuenta que tiene sus pedidos | No se crea un segundo cliente; los pedidos siguen visibles |
| R3 | La contraseña sigue funcionando después de entrar con Google | Login con emailpass responde 200 |
| R4 | Un correo nuevo sigue creando su cliente | Comportamiento actual, sin regresión |
| R5 | Un intento fallido no deja identidades sin cliente | provider_identity sin customer_id no crece |
| R6 | Un cliente dado de alta con Google puede crearse una contraseña por el flujo de recuperación y luego entrar de las dos formas | Un solo cliente con ese correo; login con emailpass y con google responden 200 |
| R7 | Cada persona queda con una sola auth_identity |
select count(*) from auth_identity where app_metadata->>'customer_id' = :id devuelve 1 |
| R8 | Borrar la cuenta no deja identidades apuntando a un cliente inexistente | La consulta de identidades con customer_id sin cliente devuelve 0 |
Riesgos¶
| Riesgo | Mitigación |
|---|---|
| Apropiación de cuenta. Si la ruta aceptara un correo como parámetro, cualquiera pediría vincularse a una cuenta ajena | La ruta no recibe ningún correo. Lee el de user_metadata de la identidad ya autenticada, que el módulo copió del id_token firmado por Google (auth-google/dist/services/google.js:100). Para vincularse a un correo hay que poder entrar a esa cuenta de Google |
| Correo no verificado en el proveedor | Ya cubierto río arriba: el módulo rechaza la autenticación si el id_token no trae email_verified (google.js:93). No hace falta repetirlo, pero sí no perderlo si algún día se agrega otro proveedor |
| Reparar los usuarios ya rotos toca datos de producción | Script aparte, idempotente, con listado previo de lo que va a cambiar y respaldo. No entra en el mismo cambio que el código |
| El correo de Google puede cambiar | La identidad se ata al cliente una sola vez, en el primer vínculo; después manda actor_id |
| Otros proveedores en el futuro | La ruta de vinculación se escribe por identidad autenticada, no específica de Google |
| La consolidación borra y recrea una credencial. Si falla a la mitad, la persona queda sin contraseña | La secuencia va dentro de un workflow, con compensación; se conserva provider_metadata tal cual, sin volver a hashear |
El token viejo de emailpass deja de resolver tras consolidar |
Es el mismo efecto que un cambio de contraseña. Se avisa en la pantalla y se fuerza a entrar de nuevo |
| Se escribe sobre las tablas del módulo de auth | Sólo con su API pública (createProviderIdentities, deleteAuthIdentities, updateAuthIdentities), nunca con SQL. auth_identity_id en la creación está documentado en el DTO (auth-identity.d.ts:107) |
Fuera de alcance¶
- Reparar los usuarios ya afectados (
ivan@compulandia.com.pyy la identidad huérfana de 2025-12-04). Es una tarea de datos, con su propia verificación. - El admin. La HU-05 lo menciona, pero no se relevó en este análisis.
- Migrar el botón a
sdk.auth.loginy el decodificado areact-jwt. Es alineación con la referencia, no corrige el defecto.
Corrección propuesta a la HU-05¶
La tarea "Permitir que un mismo usuario conserve ambos metodos de autenticacion en vez de que Google reemplace la credencial de email/password" describe algo que no ocurre. Redacción sugerida:
Vincular el login con Google a la cuenta que ya existe con ese correo, en vez de intentar crear una cuenta nueva.
Anexo · Estado de las identidades al 2026-09-08¶
Las 17 credenciales de la base, en orden de creación. Ninguna comparte
auth_identity con otra.
| Proveedor | entity_id |
Correo | Cliente | Estado |
|---|---|---|---|---|
emailpass |
ivan@compulandia.com.py | — | Huérfana (2025-09-23) | |
emailpass |
alexei.armoa@gmail.com | cus_01K5W4KQ… |
Vinculada | |
emailpass |
hquintero@compulandia.com.py | cus_01KBFF0A… |
Vinculada, 18 pedidos | |
emailpass |
hquintero1@compulandia.com.py | — | Huérfana | |
emailpass |
sirhugh777@compulandia.com.py | cus_01KAXM84… |
Vinculada, 75 pedidos | |
emailpass |
jorgeperez@gmail.com | cus_01KBJW7P… |
Vinculada | |
google |
105901768852128534026 |
mvaliente@compulandia.com.py | cus_01KBMS18… |
Vinculada |
google |
110784087114986014336 |
hquintero@compulandia.com.py | — | Huérfana · es el modo A en producción |
emailpass |
ti@compulandia.com.py | — | Huérfana | |
emailpass |
claude-test@test.com | — | Huérfana | |
google |
102177168590841697028 |
ivan@compulandia.com.py | cus_01M0WM2Q… |
Vinculada, pero duplica al invitado que tiene los 2 pedidos |
emailpass |
sirhugh777@gmail.com.py | cus_01M14JJR… |
Vinculada | |
emailpass |
jromero@test.com | cus_01M1HR7A… |
Vinculada | |
emailpass |
mvaliente@compulandia.com.py | cus_01KBMS18… |
Vinculada · mismo cliente que la de Google | |
emailpass |
jose@test.com | cus_01M1XVTW… |
Vinculada | |
google |
111985271825092679490 |
mardanval94@gmail.com | — | Huérfana (prueba del 2026-09-07) |
google |
106758319467753970636 |
no-reply@compulandia.com.py | cus_01M1YQ2W… |
Vinculada |
Dos casos merecen atención aparte:
hquintero@compulandia.com.py es el modo A ocurrido en producción, no en una
prueba: tiene su cuenta con contraseña y 18 pedidos, intentó entrar con Google el
2025-12-04 y quedó una identidad de Google huérfana. Nunca pudo entrar por esa vía.
mvaliente@compulandia.com.py es la única cuenta con las dos vías
funcionando, y funciona por casualidad: dos identidades separadas que terminaron
apuntando al mismo cliente. Es el patrón que la opción 1 replicaría a propósito y
que la opción 2 reemplazaría por una sola identidad.
Además hay 4 auth_identity sin ninguna credencial —restos de borrados— y
1 apuntando a un cliente que ya no existe. Ninguna de las dos cosas la detecta
o limpia nadie.
Las identidades de Google se registran con el id numérico de Google como clave, y las de contraseña con el correo. Son espacios de claves distintos: Medusa no puede relacionarlas por sí solo, y por eso el vínculo tiene que hacerlo la aplicación.