Saltar a contenido

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 google 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

  1. Nadie puede cruzar los dos proveedores por sí solo. Las claves son disjuntas y el correo de Google está enterrado en un jsonb.
  2. app_metadata no tiene unicidad. Nada impide dos identidades con el mismo customer_id —así funciona hoy la cuenta sana— y nada detecta que una apunte a un cliente borrado. Ya hay una en ese estado.
  3. Borrar la cuenta desvincula una sola identidad. removeCustomerAccountWorkflow toma authIdentities[0] (remove-customer-account.js:64). Con dos identidades, una queda con el customer_id de un cliente que ya no existe.
  4. 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:

  1. Crear la credencial de emailpass para ese correo si todavía no existe.
  2. 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:50 deja 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.py y 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.login y el decodificado a react-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.