Detrás de la cuenta hay compras y facturación, y un código por correo apoya toda esa puerta en el buzón: quien entre ahí, entra aquí. Delegar la identidad en un proveedor que ya tiene doble factor la sostiene mejor. Es el flujo de código de autorización con PKCE, escrito a mano porque son cien líneas y las partes que importan son tres, mejor a la vista que detrás de una dependencia: comparar el `state`, mandar el `code_verifier` y exigir que el correo venga verificado antes de enlazar nada. Lo que NO hace, a propósito: verificar la firma del `id_token`. La identidad no se saca de lo que trae el navegador, sino de una llamada nuestra al proveedor por TLS, así que no hay firma ajena que comprobar. Y no guarda ningún token: se usa una vez para preguntar quién es y se tira. El enlazado por correo verificado es lo delicado, y por eso está separado en `usuarioParaPerfil` con su explicación: sin exigir la verificación, cualquiera podría poner la dirección de otro en un perfil suyo y quedarse con su cuenta y sus compras. El código por correo se queda debajo, como alternativa. Sin credenciales puestas, los botones no se pintan y la ruta contesta 503: un botón que solo lleva a un error es peor que no tenerlo. Y los textos legales al día, que ahora sí hay terceros: qué se manda a Google y a Facebook, cuándo, qué se guarda y cómo revocarlo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
383d0ce913
commit
d3cc4e3488
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "identidad" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"proveedor" text NOT NULL,
|
||||
"sujeto" text NOT NULL,
|
||||
"usuario_id" text NOT NULL,
|
||||
"email" text,
|
||||
"creada_en" timestamp with time zone NOT NULL,
|
||||
"ultimo_acceso_en" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "identidad" ADD CONSTRAINT "identidad_usuario_id_usuario_id_fk" FOREIGN KEY ("usuario_id") REFERENCES "public"."usuario"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "identidad_proveedor_sujeto" ON "identidad" USING btree ("proveedor","sujeto");--> statement-breakpoint
|
||||
CREATE INDEX "identidad_usuario" ON "identidad" USING btree ("usuario_id");
|
||||
@ -0,0 +1,864 @@
|
||||
{
|
||||
"id": "73fba167-b287-4e75-945d-78ce984184ff",
|
||||
"prevId": "de6718aa-a69c-4f5b-8cfa-5a4a7b3a470b",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.carrito": {
|
||||
"name": "carrito",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creado_en": {
|
||||
"name": "creado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actualizado_en": {
|
||||
"name": "actualizado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"carrito_usuario": {
|
||||
"name": "carrito_usuario",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usuario_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"carrito_usuario_id_usuario_id_fk": {
|
||||
"name": "carrito_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "carrito",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.carrito_item": {
|
||||
"name": "carrito_item",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"carrito_id": {
|
||||
"name": "carrito_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"cancion_slug": {
|
||||
"name": "cancion_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"anadido_en": {
|
||||
"name": "anadido_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"carrito_item_carrito_id_carrito_id_fk": {
|
||||
"name": "carrito_item_carrito_id_carrito_id_fk",
|
||||
"tableFrom": "carrito_item",
|
||||
"tableTo": "carrito",
|
||||
"columnsFrom": [
|
||||
"carrito_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"carrito_item_carrito_id_cancion_slug_pk": {
|
||||
"name": "carrito_item_carrito_id_cancion_slug_pk",
|
||||
"columns": [
|
||||
"carrito_id",
|
||||
"cancion_slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.codigo_acceso": {
|
||||
"name": "codigo_acceso",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"hash": {
|
||||
"name": "hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expira_en": {
|
||||
"name": "expira_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"intentos": {
|
||||
"name": "intentos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"usado_en": {
|
||||
"name": "usado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creado_en": {
|
||||
"name": "creado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"codigo_acceso_email": {
|
||||
"name": "codigo_acceso_email",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "email",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.compra": {
|
||||
"name": "compra",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"cancion_slug": {
|
||||
"name": "cancion_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pedido_id": {
|
||||
"name": "pedido_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"comprado_en": {
|
||||
"name": "comprado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"compra_usuario_id_usuario_id_fk": {
|
||||
"name": "compra_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "compra",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"compra_pedido_id_pedido_id_fk": {
|
||||
"name": "compra_pedido_id_pedido_id_fk",
|
||||
"tableFrom": "compra",
|
||||
"tableTo": "pedido",
|
||||
"columnsFrom": [
|
||||
"pedido_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"compra_usuario_id_cancion_slug_pk": {
|
||||
"name": "compra_usuario_id_cancion_slug_pk",
|
||||
"columns": [
|
||||
"usuario_id",
|
||||
"cancion_slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.favorito": {
|
||||
"name": "favorito",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"cancion_slug": {
|
||||
"name": "cancion_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"marcado_en": {
|
||||
"name": "marcado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_favorito_usuario": {
|
||||
"name": "idx_favorito_usuario",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usuario_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"favorito_usuario_id_usuario_id_fk": {
|
||||
"name": "favorito_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "favorito",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"favorito_usuario_id_cancion_slug_pk": {
|
||||
"name": "favorito_usuario_id_cancion_slug_pk",
|
||||
"columns": [
|
||||
"usuario_id",
|
||||
"cancion_slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.identidad": {
|
||||
"name": "identidad",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"proveedor": {
|
||||
"name": "proveedor",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sujeto": {
|
||||
"name": "sujeto",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creada_en": {
|
||||
"name": "creada_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ultimo_acceso_en": {
|
||||
"name": "ultimo_acceso_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"identidad_proveedor_sujeto": {
|
||||
"name": "identidad_proveedor_sujeto",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "proveedor",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sujeto",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"identidad_usuario": {
|
||||
"name": "identidad_usuario",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usuario_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"identidad_usuario_id_usuario_id_fk": {
|
||||
"name": "identidad_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "identidad",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.limite": {
|
||||
"name": "limite",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"clave": {
|
||||
"name": "clave",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"intentos": {
|
||||
"name": "intentos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expira_en": {
|
||||
"name": "expira_en",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"limite_expira": {
|
||||
"name": "limite_expira",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expira_en",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pedido": {
|
||||
"name": "pedido",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sesion_pago_id": {
|
||||
"name": "sesion_pago_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"estado": {
|
||||
"name": "estado",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pendiente'"
|
||||
},
|
||||
"packs": {
|
||||
"name": "packs",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"total_centimos": {
|
||||
"name": "total_centimos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"moneda": {
|
||||
"name": "moneda",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'eur'"
|
||||
},
|
||||
"creado_en": {
|
||||
"name": "creado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pagado_en": {
|
||||
"name": "pagado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"pedido_usuario": {
|
||||
"name": "pedido_usuario",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usuario_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"pedido_usuario_id_usuario_id_fk": {
|
||||
"name": "pedido_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "pedido",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pedido_sesion_pago_id_unique": {
|
||||
"name": "pedido_sesion_pago_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"sesion_pago_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pedido_item": {
|
||||
"name": "pedido_item",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"pedido_id": {
|
||||
"name": "pedido_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"cancion_slug": {
|
||||
"name": "cancion_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"titulo": {
|
||||
"name": "titulo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pedido_item_pedido_id_pedido_id_fk": {
|
||||
"name": "pedido_item_pedido_id_pedido_id_fk",
|
||||
"tableFrom": "pedido_item",
|
||||
"tableTo": "pedido",
|
||||
"columnsFrom": [
|
||||
"pedido_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"pedido_item_pedido_id_cancion_slug_pk": {
|
||||
"name": "pedido_item_pedido_id_cancion_slug_pk",
|
||||
"columns": [
|
||||
"pedido_id",
|
||||
"cancion_slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sesion": {
|
||||
"name": "sesion",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expira_en": {
|
||||
"name": "expira_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"creada_en": {
|
||||
"name": "creada_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sesion_usuario": {
|
||||
"name": "sesion_usuario",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usuario_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sesion_usuario_id_usuario_id_fk": {
|
||||
"name": "sesion_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "sesion",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.usuario": {
|
||||
"name": "usuario",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"nombre": {
|
||||
"name": "nombre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creado_en": {
|
||||
"name": "creado_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ultimo_acceso_en": {
|
||||
"name": "ultimo_acceso_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"usuario_email_unique": {
|
||||
"name": "usuario_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.valoracion": {
|
||||
"name": "valoracion",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"usuario_id": {
|
||||
"name": "usuario_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"cancion_slug": {
|
||||
"name": "cancion_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"puntuacion": {
|
||||
"name": "puntuacion",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"creada_en": {
|
||||
"name": "creada_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actualizada_en": {
|
||||
"name": "actualizada_en",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_valoracion_cancion": {
|
||||
"name": "idx_valoracion_cancion",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "cancion_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"valoracion_usuario_id_usuario_id_fk": {
|
||||
"name": "valoracion_usuario_id_usuario_id_fk",
|
||||
"tableFrom": "valoracion",
|
||||
"tableTo": "usuario",
|
||||
"columnsFrom": [
|
||||
"usuario_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"valoracion_usuario_id_cancion_slug_pk": {
|
||||
"name": "valoracion_usuario_id_cancion_slug_pk",
|
||||
"columns": [
|
||||
"usuario_id",
|
||||
"cancion_slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* «Continuar con Google» y «Continuar con Facebook».
|
||||
*
|
||||
* Son enlaces, no botones ni formularios: la ida al proveedor es un `GET` que
|
||||
* redirige, y así funcionan igual sin JavaScript y se pueden abrir con el
|
||||
* teclado como cualquier enlace.
|
||||
*
|
||||
* El logotipo va dibujado aquí y no traído de un CDN. Dos razones: cargarlo
|
||||
* de fuera le contaría a Google que has abierto esta página antes de que
|
||||
* hayas decidido nada, que es justo lo que dice la política de privacidad que
|
||||
* no se hace; y las dos marcas exigen su color exacto, que no se respeta si
|
||||
* se tiñe un icono genérico.
|
||||
*/
|
||||
import type { Proveedor } from '$lib/types';
|
||||
|
||||
let {
|
||||
proveedores = [] as Proveedor[],
|
||||
/** A dónde volver una vez dentro. */
|
||||
volverA = '/cuenta'
|
||||
} = $props();
|
||||
|
||||
const NOMBRES: Record<Proveedor, string> = { google: 'Google', facebook: 'Facebook' };
|
||||
|
||||
const enlace = (proveedor: Proveedor) =>
|
||||
`/acceso/${proveedor}?volverA=${encodeURIComponent(volverA)}`;
|
||||
</script>
|
||||
|
||||
{#if proveedores.length > 0}
|
||||
<ul class="proveedores">
|
||||
{#each proveedores as proveedor (proveedor)}
|
||||
<li>
|
||||
<a class="btn btn--ghost proveedor" href={enlace(proveedor)} data-proveedor={proveedor}>
|
||||
{#if proveedor === 'google'}
|
||||
<!-- La G de cuatro colores, tal cual la exige su guía de marca. -->
|
||||
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3-2.33Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.9 11.43 0 9 0A9 9 0 0 0 .96 4.95l3 2.33C4.68 5.16 6.66 3.58 9 3.58Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
|
||||
<path
|
||||
fill="#1877F2"
|
||||
d="M18 9a9 9 0 1 0-10.41 8.89v-6.29H5.31V9h2.28V7.02c0-2.25 1.34-3.5 3.4-3.5.98 0 2.01.18 2.01.18v2.21h-1.13c-1.12 0-1.47.7-1.47 1.4V9h2.5l-.4 2.6h-2.1v6.29A9 9 0 0 0 18 9Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Continuar con {NOMBRES[proveedor]}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!--
|
||||
El separador es decorativo: la palabra ya está en el marcado, y para quien
|
||||
usa un lector de pantalla las dos formas de entrar se distinguen solas por
|
||||
lo que dicen sus controles.
|
||||
-->
|
||||
<p class="separador" aria-hidden="true"><span>o con tu correo</span></p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.proveedores {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
list-style: none;
|
||||
margin: 0 0 var(--sp-4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.proveedor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* El logotipo no se tiñe con el color del texto: son marcas ajenas y su color
|
||||
* es parte de ellas. `flex-shrink` a cero para que no se aplaste cuando el
|
||||
* nombre es largo.
|
||||
*/
|
||||
.proveedor svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.separador {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.separador::before,
|
||||
.separador::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--c-border);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,309 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { comenzar, completar, urlDeRetorno } from './oauth';
|
||||
|
||||
/*
|
||||
* Las credenciales se leen de `$env/dynamic/private`, que bajo Vitest no sigue
|
||||
* a `process.env`: lo resuelve el propio SvelteKit y queda fijado al cargar el
|
||||
* módulo. Así que se sustituye por un objeto que sí se puede tocar desde aquí.
|
||||
*
|
||||
* `vi.hoisted` porque `vi.mock` se sube al principio del archivo: sin eso, la
|
||||
* fábrica se ejecuta antes de que exista la variable.
|
||||
*/
|
||||
const entorno = vi.hoisted(
|
||||
() =>
|
||||
({
|
||||
/*
|
||||
* El módulo de base de datos se niega a cargar sin esto, y `oauth.ts` lo
|
||||
* importa. No se conecta a nada: el pool no abre conexión hasta la primera
|
||||
* consulta, y aquí no se hace ninguna.
|
||||
*/
|
||||
DATABASE_URL: 'postgresql://nadie@localhost:5432/ninguna'
|
||||
}) as Record<string, string | undefined>
|
||||
);
|
||||
vi.mock('$env/dynamic/private', () => ({ env: entorno }));
|
||||
|
||||
/**
|
||||
* Un armario de cookies de mentira, con lo justo que usa el módulo.
|
||||
*
|
||||
* Se escribe a mano en vez de traerse la implementación de SvelteKit porque lo
|
||||
* que interesa comprobar es qué se guarda y qué se borra, y para eso un mapa
|
||||
* basta y se lee de un vistazo.
|
||||
*/
|
||||
function armario(inicial: Record<string, string> = {}) {
|
||||
const dentro = new Map(Object.entries(inicial));
|
||||
const puestas: { nombre: string; valor: string; opciones: Record<string, unknown> }[] = [];
|
||||
const borradas: string[] = [];
|
||||
|
||||
const cookies = {
|
||||
get: (nombre: string) => dentro.get(nombre),
|
||||
set: (nombre: string, valor: string, opciones: Record<string, unknown>) => {
|
||||
dentro.set(nombre, valor);
|
||||
puestas.push({ nombre, valor, opciones });
|
||||
},
|
||||
delete: (nombre: string) => {
|
||||
dentro.delete(nombre);
|
||||
borradas.push(nombre);
|
||||
}
|
||||
} as unknown as Cookies;
|
||||
|
||||
return { cookies, puestas, borradas, dentro };
|
||||
}
|
||||
|
||||
const ORIGEN = 'https://senzapaura.example';
|
||||
|
||||
beforeEach(() => {
|
||||
// `comenzar` y `completar` no arrancan sin credenciales.
|
||||
entorno.GOOGLE_CLIENT_ID = 'id-de-prueba';
|
||||
entorno.GOOGLE_CLIENT_SECRET = 'secreto-de-prueba';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete entorno.GOOGLE_CLIENT_ID;
|
||||
delete entorno.GOOGLE_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
describe('urlDeRetorno', () => {
|
||||
it('se compone del origen público, no de lo que llegue en la petición', () => {
|
||||
// Tomarla del `Host` de la petición dejaría inducir una redirección a otro
|
||||
// sitio; además tiene que coincidir con la dada de alta en el proveedor.
|
||||
expect(urlDeRetorno('google', ORIGEN)).toBe(`${ORIGEN}/acceso/google/retorno`);
|
||||
expect(urlDeRetorno('facebook', 'http://localhost:3000')).toBe(
|
||||
'http://localhost:3000/acceso/facebook/retorno'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comenzar', () => {
|
||||
it('manda el reto de PKCE, no el secreto', () => {
|
||||
const { cookies, dentro } = armario();
|
||||
|
||||
const url = new URL(comenzar('google', cookies, ORIGEN, '/musica'));
|
||||
const verificador = dentro.get('oauth_verificador')!;
|
||||
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(url.searchParams.get('code_challenge')).toBe(
|
||||
createHash('sha256').update(verificador).digest('base64url')
|
||||
);
|
||||
// El verificador se queda aquí: si viajara, PKCE no protegería de nada.
|
||||
expect(url.toString()).not.toContain(verificador);
|
||||
});
|
||||
|
||||
it('lleva un state y lo guarda para poder compararlo a la vuelta', () => {
|
||||
const { cookies, dentro } = armario();
|
||||
|
||||
const url = new URL(comenzar('google', cookies, ORIGEN, '/musica'));
|
||||
|
||||
expect(url.searchParams.get('state')).toBe(dentro.get('oauth_estado'));
|
||||
expect(dentro.get('oauth_estado')!.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('cada ida estrena state y verificador', () => {
|
||||
const uno = armario();
|
||||
const otro = armario();
|
||||
|
||||
comenzar('google', uno.cookies, ORIGEN, '/');
|
||||
comenzar('google', otro.cookies, ORIGEN, '/');
|
||||
|
||||
expect(uno.dentro.get('oauth_estado')).not.toBe(otro.dentro.get('oauth_estado'));
|
||||
expect(uno.dentro.get('oauth_verificador')).not.toBe(otro.dentro.get('oauth_verificador'));
|
||||
});
|
||||
|
||||
it('las cookies no se leen desde JavaScript y sobreviven a la vuelta', () => {
|
||||
const { cookies, puestas } = armario();
|
||||
|
||||
comenzar('google', cookies, ORIGEN, '/');
|
||||
|
||||
for (const { opciones } of puestas) {
|
||||
expect(opciones.httpOnly).toBe(true);
|
||||
// `strict` no valdría: la vuelta es una navegación desde otro sitio y el
|
||||
// navegador no mandaría la cookie, así que no habría con qué comparar.
|
||||
expect(opciones.sameSite).toBe('lax');
|
||||
expect(opciones.secure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('sin HTTPS no marca las cookies como seguras, o no se pondrían en local', () => {
|
||||
const { cookies, puestas } = armario();
|
||||
|
||||
comenzar('google', cookies, 'http://localhost:5173', '/');
|
||||
|
||||
expect(puestas.every((p) => p.opciones.secure === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('no pide más permisos que saber quién es', () => {
|
||||
const { cookies } = armario();
|
||||
|
||||
const url = new URL(comenzar('google', cookies, ORIGEN, '/'));
|
||||
|
||||
expect(url.searchParams.get('scope')).toBe('openid email profile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completar', () => {
|
||||
const vuelta = (estado: string, codigo = 'codigo-del-proveedor') =>
|
||||
new URL(`${ORIGEN}/acceso/google/retorno?state=${estado}&code=${codigo}`);
|
||||
|
||||
it('rechaza una vuelta cuyo state no es el que se guardó', async () => {
|
||||
/*
|
||||
* Es la defensa contra que alguien te haga entrar en SU cuenta: prepara una
|
||||
* vuelta con su propio código y te la hace visitar. Si el state no coincide,
|
||||
* esa vuelta no la pediste tú.
|
||||
*/
|
||||
const { cookies } = armario({
|
||||
oauth_estado: 'el-mio',
|
||||
oauth_verificador: 'v'
|
||||
});
|
||||
|
||||
const r = await completar('google', cookies, vuelta('el-de-otro'), ORIGEN);
|
||||
|
||||
expect(r).toEqual({ ok: false, motivo: 'estado' });
|
||||
});
|
||||
|
||||
it('rechaza una vuelta sin state guardado', async () => {
|
||||
const { cookies } = armario();
|
||||
|
||||
const r = await completar('google', cookies, vuelta('cualquiera'), ORIGEN);
|
||||
|
||||
expect(r).toEqual({ ok: false, motivo: 'estado' });
|
||||
});
|
||||
|
||||
it('borra las cookies de un solo uso aunque la vuelta sea mala', async () => {
|
||||
// Si se quedaran, un state válido serviría dos veces.
|
||||
const { cookies, borradas } = armario({ oauth_estado: 'x', oauth_verificador: 'v' });
|
||||
|
||||
await completar('google', cookies, vuelta('otro'), ORIGEN);
|
||||
|
||||
expect(borradas).toEqual(
|
||||
expect.arrayContaining(['oauth_estado', 'oauth_verificador', 'oauth_vuelta'])
|
||||
);
|
||||
});
|
||||
|
||||
it('manda el verificador al canjear el código', async () => {
|
||||
const peticiones: { url: string; cuerpo: string }[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (entrada: string, opciones?: { body?: URLSearchParams }) => {
|
||||
peticiones.push({ url: String(entrada), cuerpo: String(opciones?.body ?? '') });
|
||||
if (String(entrada).includes('token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'un-token' }), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ sub: '123', email: 'ana@example.com', email_verified: true }),
|
||||
{ status: 200 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const { cookies } = armario({
|
||||
oauth_estado: 'coincide',
|
||||
oauth_verificador: 'el-verificador',
|
||||
oauth_vuelta: '/musica'
|
||||
});
|
||||
|
||||
const r = await completar('google', cookies, vuelta('coincide'), ORIGEN);
|
||||
|
||||
expect(r).toEqual({
|
||||
ok: true,
|
||||
volverA: '/musica',
|
||||
perfil: { sujeto: '123', email: 'ana@example.com', emailVerificado: true, nombre: null }
|
||||
});
|
||||
expect(peticiones[0].cuerpo).toContain('code_verifier=el-verificador');
|
||||
// El secreto viaja de servidor a servidor, nunca por el navegador.
|
||||
expect(peticiones[0].cuerpo).toContain('client_secret=secreto-de-prueba');
|
||||
});
|
||||
|
||||
it('no acepta un correo que Google no da por verificado', async () => {
|
||||
/*
|
||||
* Es la llave de todo el enlazado: si se aceptara sin verificar, cualquiera
|
||||
* podría poner la dirección de otro en un perfil suyo y quedarse con su
|
||||
* cuenta, sus compras incluidas.
|
||||
*/
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (entrada: string) =>
|
||||
String(entrada).includes('token')
|
||||
? new Response(JSON.stringify({ access_token: 't' }), { status: 200 })
|
||||
: new Response(
|
||||
JSON.stringify({ sub: '9', email: 'victima@example.com', email_verified: false }),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { cookies } = armario({ oauth_estado: 'ok', oauth_verificador: 'v' });
|
||||
|
||||
const r = await completar('google', cookies, vuelta('ok'), ORIGEN);
|
||||
|
||||
expect(r).toEqual({ ok: false, motivo: 'sin-correo' });
|
||||
});
|
||||
|
||||
it('no sigue si el proveedor no da correo', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (entrada: string) =>
|
||||
String(entrada).includes('token')
|
||||
? new Response(JSON.stringify({ access_token: 't' }), { status: 200 })
|
||||
: new Response(JSON.stringify({ sub: '9', email_verified: true }), { status: 200 })
|
||||
)
|
||||
);
|
||||
|
||||
const { cookies } = armario({ oauth_estado: 'ok', oauth_verificador: 'v' });
|
||||
|
||||
expect(await completar('google', cookies, vuelta('ok'), ORIGEN)).toEqual({
|
||||
ok: false,
|
||||
motivo: 'sin-correo'
|
||||
});
|
||||
});
|
||||
|
||||
it('normaliza el correo, para que case con el de una cuenta ya existente', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (entrada: string) =>
|
||||
String(entrada).includes('token')
|
||||
? new Response(JSON.stringify({ access_token: 't' }), { status: 200 })
|
||||
: new Response(
|
||||
JSON.stringify({ sub: '1', email: ' Ana@Example.COM ', email_verified: true }),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { cookies } = armario({ oauth_estado: 'ok', oauth_verificador: 'v' });
|
||||
const r = await completar('google', cookies, vuelta('ok'), ORIGEN);
|
||||
|
||||
expect(r.ok && r.perfil.email).toBe('ana@example.com');
|
||||
});
|
||||
|
||||
it('aguanta que el proveedor conteste con un error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('vaya', { status: 500 }))
|
||||
);
|
||||
|
||||
const { cookies } = armario({ oauth_estado: 'ok', oauth_verificador: 'v' });
|
||||
|
||||
expect(await completar('google', cookies, vuelta('ok'), ORIGEN)).toEqual({
|
||||
ok: false,
|
||||
motivo: 'codigo'
|
||||
});
|
||||
});
|
||||
|
||||
it('aguanta que el proveedor no conteste', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('la red se fue');
|
||||
})
|
||||
);
|
||||
|
||||
const { cookies } = armario({ oauth_estado: 'ok', oauth_verificador: 'v' });
|
||||
|
||||
expect(await completar('google', cookies, vuelta('ok'), ORIGEN)).toEqual({
|
||||
ok: false,
|
||||
motivo: 'proveedor'
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Entrar con Google o con Facebook.
|
||||
*
|
||||
* Es el flujo de código de autorización con PKCE, el estándar para una
|
||||
* aplicación con servidor. En resumen:
|
||||
*
|
||||
* 1. Mandamos a la persona al proveedor con un `state` y un reto PKCE.
|
||||
* 2. El proveedor la devuelve aquí con un código de un solo uso.
|
||||
* 3. El servidor cambia ese código por un token, de servidor a servidor.
|
||||
* 4. Con el token pregunta quién es, una vez, y lo tira.
|
||||
*
|
||||
* ## Por qué está escrito a mano y no con una biblioteca
|
||||
*
|
||||
* Porque son cien líneas y las partes delicadas son tres, todas aquí a la
|
||||
* vista: comparar el `state`, mandar el `code_verifier` y comprobar que el
|
||||
* correo viene verificado antes de enlazar nada. El intercambio en sí es un
|
||||
* `POST` con `application/x-www-form-urlencoded`.
|
||||
*
|
||||
* Lo que NO se hace, a propósito: verificar la firma del `id_token`. No hace
|
||||
* falta, y por eso no hay aquí ni JWKS ni criptografía de curvas. La identidad
|
||||
* no se saca del token que trae el navegador, sino de una llamada nuestra al
|
||||
* proveedor por TLS; lo que llega por ahí no ha pasado por manos ajenas. Es la
|
||||
* diferencia entre el flujo implícito —donde sí habría que verificar firmas— y
|
||||
* este.
|
||||
*
|
||||
* ## Lo que se guarda
|
||||
*
|
||||
* El identificador que da el proveedor y poco más. Ningún token: se usa para
|
||||
* preguntar el nombre y el correo, y se descarta. No pedimos permisos para
|
||||
* nada más, así que conservarlo solo añadiría material que puede filtrarse.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { db } from './db';
|
||||
import { identidad, usuario, type Usuario } from './db/schema';
|
||||
import { normalizarEmail } from './auth';
|
||||
import type { Proveedor } from '$lib/types';
|
||||
|
||||
export type { Proveedor };
|
||||
|
||||
/** Lo que hace falta saber de cada proveedor. */
|
||||
interface Configuracion {
|
||||
nombre: string;
|
||||
autorizar: string;
|
||||
token: string;
|
||||
perfil: string;
|
||||
/** Lo mínimo para saber quién es. Nada de contactos ni de publicar. */
|
||||
alcance: string;
|
||||
idCliente: () => string | undefined;
|
||||
secreto: () => string | undefined;
|
||||
/** Traduce la respuesta del proveedor a lo que aquí se necesita. */
|
||||
leerPerfil: (datos: Record<string, unknown>) => PerfilExterno | null;
|
||||
}
|
||||
|
||||
/** Quién es, según el proveedor. */
|
||||
export interface PerfilExterno {
|
||||
/** Identificador estable en ese proveedor. */
|
||||
sujeto: string;
|
||||
email: string | null;
|
||||
/** Si el proveedor afirma que esa dirección está verificada. */
|
||||
emailVerificado: boolean;
|
||||
nombre: string | null;
|
||||
}
|
||||
|
||||
const PROVEEDORES: Record<Proveedor, Configuracion> = {
|
||||
google: {
|
||||
nombre: 'Google',
|
||||
autorizar: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
token: 'https://oauth2.googleapis.com/token',
|
||||
perfil: 'https://openidconnect.googleapis.com/v1/userinfo',
|
||||
alcance: 'openid email profile',
|
||||
idCliente: () => env.GOOGLE_CLIENT_ID,
|
||||
secreto: () => env.GOOGLE_CLIENT_SECRET,
|
||||
leerPerfil: (d) => {
|
||||
const sujeto = typeof d.sub === 'string' ? d.sub : null;
|
||||
if (!sujeto) return null;
|
||||
return {
|
||||
sujeto,
|
||||
email: typeof d.email === 'string' ? d.email : null,
|
||||
// Google lo dice explícitamente. Se exige `true`, no «que venga»:
|
||||
// una cuenta con el correo sin verificar no puede enlazarse.
|
||||
emailVerificado: d.email_verified === true,
|
||||
nombre: typeof d.name === 'string' ? d.name : null
|
||||
};
|
||||
}
|
||||
},
|
||||
facebook: {
|
||||
nombre: 'Facebook',
|
||||
autorizar: 'https://www.facebook.com/v21.0/dialog/oauth',
|
||||
token: 'https://graph.facebook.com/v21.0/oauth/access_token',
|
||||
perfil: 'https://graph.facebook.com/v21.0/me?fields=id,name,email',
|
||||
alcance: 'public_profile,email',
|
||||
idCliente: () => env.FACEBOOK_CLIENT_ID,
|
||||
secreto: () => env.FACEBOOK_CLIENT_SECRET,
|
||||
leerPerfil: (d) => {
|
||||
const sujeto = typeof d.id === 'string' ? d.id : null;
|
||||
if (!sujeto) return null;
|
||||
const email = typeof d.email === 'string' ? d.email : null;
|
||||
return {
|
||||
sujeto,
|
||||
email,
|
||||
/*
|
||||
* Facebook no manda un campo de verificación: solo devuelve el correo
|
||||
* cuando está confirmado en la cuenta, y lo omite si no lo está. Que
|
||||
* venga es, en su modelo, la confirmación.
|
||||
*/
|
||||
emailVerificado: email !== null,
|
||||
nombre: typeof d.name === 'string' ? d.name : null
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function nombreDeProveedor(proveedor: Proveedor): string {
|
||||
return PROVEEDORES[proveedor].nombre;
|
||||
}
|
||||
|
||||
export function esProveedor(valor: string): valor is Proveedor {
|
||||
return valor === 'google' || valor === 'facebook';
|
||||
}
|
||||
|
||||
/** Un proveedor solo se ofrece si tiene credenciales puestas. */
|
||||
export function proveedorConfigurado(proveedor: Proveedor): boolean {
|
||||
const p = PROVEEDORES[proveedor];
|
||||
return Boolean(p.idCliente() && p.secreto());
|
||||
}
|
||||
|
||||
export function proveedoresDisponibles(): Proveedor[] {
|
||||
return (Object.keys(PROVEEDORES) as Proveedor[]).filter(proveedorConfigurado);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Ida
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
const COOKIE_ESTADO = 'oauth_estado';
|
||||
const COOKIE_VERIFICADOR = 'oauth_verificador';
|
||||
const COOKIE_VUELTA = 'oauth_vuelta';
|
||||
/** Diez minutos: lo que puede tardar alguien en escribir su contraseña allí. */
|
||||
const VIDA_COOKIE = 10 * 60;
|
||||
|
||||
function aleatorioUrlSeguro(bytes = 32): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* La dirección a la que el proveedor devuelve.
|
||||
*
|
||||
* Se compone del origen público y no de la petición: si se tomara del `Host`
|
||||
* que llega, alguien podría inducir una redirección a otro sitio. Además tiene
|
||||
* que coincidir carácter a carácter con la que esté dada de alta en la consola
|
||||
* del proveedor, o el intercambio se rechaza.
|
||||
*/
|
||||
export function urlDeRetorno(proveedor: Proveedor, origen: string): string {
|
||||
return new URL(`/acceso/${proveedor}/retorno`, origen).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepara la ida: guarda los secretos de un solo uso en cookies y devuelve la
|
||||
* dirección del proveedor.
|
||||
*/
|
||||
export function comenzar(
|
||||
proveedor: Proveedor,
|
||||
cookies: Cookies,
|
||||
origen: string,
|
||||
volverA: string
|
||||
): string {
|
||||
const p = PROVEEDORES[proveedor];
|
||||
const idCliente = p.idCliente();
|
||||
if (!idCliente) throw new Error(`Falta la configuración de ${p.nombre}`);
|
||||
|
||||
const estado = aleatorioUrlSeguro();
|
||||
const verificador = aleatorioUrlSeguro();
|
||||
const reto = createHash('sha256').update(verificador).digest('base64url');
|
||||
|
||||
/*
|
||||
* `sameSite: 'lax'` y no `strict`: la vuelta del proveedor es una navegación
|
||||
* desde otro sitio, y con `strict` el navegador no mandaría estas cookies.
|
||||
* Sin ellas no habría con qué comparar el `state` y todo acceso fallaría.
|
||||
*/
|
||||
const opciones = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: origen.startsWith('https://'),
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: VIDA_COOKIE
|
||||
};
|
||||
cookies.set(COOKIE_ESTADO, estado, opciones);
|
||||
cookies.set(COOKIE_VERIFICADOR, verificador, opciones);
|
||||
cookies.set(COOKIE_VUELTA, volverA, opciones);
|
||||
|
||||
const url = new URL(p.autorizar);
|
||||
url.searchParams.set('client_id', idCliente);
|
||||
url.searchParams.set('redirect_uri', urlDeRetorno(proveedor, origen));
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', p.alcance);
|
||||
url.searchParams.set('state', estado);
|
||||
url.searchParams.set('code_challenge', reto);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
// Sin esto, Google salta el selector cuando ya hay una sesión suya abierta y
|
||||
// no hay forma de entrar con otra cuenta.
|
||||
if (proveedor === 'google') url.searchParams.set('prompt', 'select_account');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Vuelta
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
export type ResultadoVuelta =
|
||||
| { ok: true; perfil: PerfilExterno; volverA: string }
|
||||
| { ok: false; motivo: 'estado' | 'codigo' | 'proveedor' | 'sin-correo' };
|
||||
|
||||
/** Comparación en tiempo constante de dos cadenas. */
|
||||
function iguales(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/** Borra las cookies de un solo uso, pase lo que pase. */
|
||||
function limpiar(cookies: Cookies): void {
|
||||
for (const nombre of [COOKIE_ESTADO, COOKIE_VERIFICADOR, COOKIE_VUELTA]) {
|
||||
cookies.delete(nombre, { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la vuelta del proveedor y devuelve quién es.
|
||||
*
|
||||
* No abre sesión: eso lo decide quien llama, después de enlazar la identidad.
|
||||
*/
|
||||
export async function completar(
|
||||
proveedor: Proveedor,
|
||||
cookies: Cookies,
|
||||
url: URL,
|
||||
origen: string
|
||||
): Promise<ResultadoVuelta> {
|
||||
const p = PROVEEDORES[proveedor];
|
||||
const esperado = cookies.get(COOKIE_ESTADO);
|
||||
const verificador = cookies.get(COOKIE_VERIFICADOR);
|
||||
const volverA = cookies.get(COOKIE_VUELTA) ?? '/cuenta';
|
||||
const recibido = url.searchParams.get('state');
|
||||
const codigo = url.searchParams.get('code');
|
||||
|
||||
limpiar(cookies);
|
||||
|
||||
/*
|
||||
* El `state` es lo que impide que alguien te haga entrar en SU cuenta sin que
|
||||
* te enteres: prepara una vuelta con su propio código y te la hace visitar.
|
||||
* Si el `state` no es el que guardamos al empezar, esa vuelta no la pediste.
|
||||
*/
|
||||
if (!esperado || !recibido || !iguales(esperado, recibido)) {
|
||||
return { ok: false, motivo: 'estado' };
|
||||
}
|
||||
if (!codigo || !verificador) return { ok: false, motivo: 'codigo' };
|
||||
|
||||
const idCliente = p.idCliente();
|
||||
const secreto = p.secreto();
|
||||
if (!idCliente || !secreto) return { ok: false, motivo: 'proveedor' };
|
||||
|
||||
let acceso: string;
|
||||
try {
|
||||
const respuesta = await fetch(p.token, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
accept: 'application/json'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: codigo,
|
||||
client_id: idCliente,
|
||||
client_secret: secreto,
|
||||
redirect_uri: urlDeRetorno(proveedor, origen),
|
||||
code_verifier: verificador
|
||||
})
|
||||
});
|
||||
|
||||
if (!respuesta.ok) {
|
||||
console.error(`[acceso] ${p.nombre} rechazó el canje`, respuesta.status);
|
||||
return { ok: false, motivo: 'codigo' };
|
||||
}
|
||||
|
||||
const datos = (await respuesta.json()) as { access_token?: unknown };
|
||||
if (typeof datos.access_token !== 'string') return { ok: false, motivo: 'proveedor' };
|
||||
acceso = datos.access_token;
|
||||
} catch (causa) {
|
||||
console.error(`[acceso] No se ha podido hablar con ${p.nombre}`, causa);
|
||||
return { ok: false, motivo: 'proveedor' };
|
||||
}
|
||||
|
||||
let perfil: PerfilExterno | null;
|
||||
try {
|
||||
const respuesta = await fetch(p.perfil, {
|
||||
headers: { authorization: `Bearer ${acceso}`, accept: 'application/json' }
|
||||
});
|
||||
if (!respuesta.ok) return { ok: false, motivo: 'proveedor' };
|
||||
perfil = p.leerPerfil((await respuesta.json()) as Record<string, unknown>);
|
||||
} catch (causa) {
|
||||
console.error(`[acceso] No se ha podido leer el perfil en ${p.nombre}`, causa);
|
||||
return { ok: false, motivo: 'proveedor' };
|
||||
}
|
||||
|
||||
if (!perfil) return { ok: false, motivo: 'proveedor' };
|
||||
|
||||
/*
|
||||
* Sin correo verificado no se sigue. Hace falta para dos cosas distintas:
|
||||
* para mandar la factura y las descargas, y —sobre todo— para poder decidir
|
||||
* si esta cuenta es la misma persona que un usuario que ya existe aquí.
|
||||
*/
|
||||
if (!perfil.email || !perfil.emailVerificado) return { ok: false, motivo: 'sin-correo' };
|
||||
|
||||
return { ok: true, perfil: { ...perfil, email: normalizarEmail(perfil.email) }, volverA };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Enlazar con una cuenta de aqui
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Devuelve el usuario correspondiente a un perfil externo, creándolo si hace
|
||||
* falta.
|
||||
*
|
||||
* El orden importa y no es casual:
|
||||
*
|
||||
* 1. **Por identidad.** Si esa cuenta de Google ya está enlazada, es esa y ya.
|
||||
* 2. **Por correo verificado.** Si hay un usuario con ese correo —porque entró
|
||||
* antes con un código— se enlaza. Es la misma persona: el proveedor afirma
|
||||
* que controla esa dirección, y controlar la dirección es exactamente lo que
|
||||
* demuestra el código por correo. No enlazar aquí crearía cuentas
|
||||
* duplicadas, y la de las compras sería la que no puede abrir.
|
||||
* 3. **Nueva.**
|
||||
*
|
||||
* El paso 2 es el delicado, y por eso arriba se exige la verificación: enlazar
|
||||
* por un correo que el proveedor no garantiza sería regalar la cuenta a quien
|
||||
* ponga esa dirección en un perfil suyo.
|
||||
*/
|
||||
export async function usuarioParaPerfil(
|
||||
proveedor: Proveedor,
|
||||
perfil: PerfilExterno & { email: string }
|
||||
): Promise<Usuario> {
|
||||
const ahora = new Date();
|
||||
|
||||
const [enlazada] = await db
|
||||
.select()
|
||||
.from(identidad)
|
||||
.where(and(eq(identidad.proveedor, proveedor), eq(identidad.sujeto, perfil.sujeto)))
|
||||
.limit(1);
|
||||
|
||||
if (enlazada) {
|
||||
await db
|
||||
.update(identidad)
|
||||
.set({ ultimoAccesoEn: ahora, email: perfil.email })
|
||||
.where(eq(identidad.id, enlazada.id));
|
||||
|
||||
const [ya] = await db.select().from(usuario).where(eq(usuario.id, enlazada.usuarioId)).limit(1);
|
||||
if (ya) {
|
||||
await db.update(usuario).set({ ultimoAccesoEn: ahora }).where(eq(usuario.id, ya.id));
|
||||
return { ...ya, ultimoAccesoEn: ahora };
|
||||
}
|
||||
}
|
||||
|
||||
const [existente] = await db
|
||||
.select()
|
||||
.from(usuario)
|
||||
.where(eq(usuario.email, perfil.email))
|
||||
.limit(1);
|
||||
|
||||
const cuenta =
|
||||
existente ??
|
||||
(
|
||||
await db.insert(usuario).values({ email: perfil.email, nombre: perfil.nombre }).returning()
|
||||
)[0];
|
||||
|
||||
// Si la cuenta existía sin nombre y el proveedor da uno, se aprovecha.
|
||||
if (existente && !existente.nombre && perfil.nombre) {
|
||||
await db.update(usuario).set({ nombre: perfil.nombre }).where(eq(usuario.id, cuenta.id));
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(identidad)
|
||||
.values({
|
||||
proveedor,
|
||||
sujeto: perfil.sujeto,
|
||||
usuarioId: cuenta.id,
|
||||
email: perfil.email,
|
||||
ultimoAccesoEn: ahora
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await db.update(usuario).set({ ultimoAccesoEn: ahora }).where(eq(usuario.id, cuenta.id));
|
||||
|
||||
return { ...cuenta, ultimoAccesoEn: ahora };
|
||||
}
|
||||
|
||||
/** Con qué proveedores ha entrado alguien. Lo enseña su cuenta. */
|
||||
export async function identidadesDe(usuarioId: string): Promise<Proveedor[]> {
|
||||
const filas = await db
|
||||
.select({ proveedor: identidad.proveedor })
|
||||
.from(identidad)
|
||||
.where(eq(identidad.usuarioId, usuarioId));
|
||||
return filas.map((f) => f.proveedor);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Ida: manda al proveedor.
|
||||
*
|
||||
* Es un `GET` que redirige, no una acción de formulario, porque el destino es
|
||||
* otro sitio: no hay nada que enviar, solo a dónde ir. Y por eso mismo no lleva
|
||||
* cuerpo ni cambia nada aquí salvo dejar puestas las cookies de un solo uso.
|
||||
*/
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { comenzar, esProveedor, proveedorConfigurado } from '$lib/server/oauth';
|
||||
import { destinoSeguro } from '$lib/server/destino';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = ({ params, url, cookies, locals }) => {
|
||||
if (!esProveedor(params.proveedor)) error(404, 'Ese proveedor no existe.');
|
||||
if (!proveedorConfigurado(params.proveedor)) {
|
||||
error(503, 'Ese proveedor no está configurado en este servidor.');
|
||||
}
|
||||
|
||||
// Ya dentro: no tiene sentido volver a entrar.
|
||||
if (locals.usuario) redirect(303, destinoSeguro(url.searchParams.get('volverA'), '/cuenta'));
|
||||
|
||||
const volverA = destinoSeguro(url.searchParams.get('volverA'), '/cuenta');
|
||||
redirect(303, comenzar(params.proveedor, cookies, url.origin, volverA));
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Vuelta: el proveedor devuelve aquí con un código.
|
||||
*
|
||||
* Si todo cuadra se abre sesión y se sigue a donde se estaba. Si no, se vuelve
|
||||
* a /entrar con el motivo, que se cuenta en cristiano: nadie sabe qué es un
|
||||
* `state` que no coincide, pero sí entiende «vuelve a intentarlo».
|
||||
*/
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { crearSesion, establecerCookieSesion } from '$lib/server/auth';
|
||||
import { fusionarCarrito } from '$lib/server/carrito';
|
||||
import { completar, esProveedor, usuarioParaPerfil } from '$lib/server/oauth';
|
||||
import { destinoSeguro } from '$lib/server/destino';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** Lo que se le cuenta a quien vuelve con las manos vacías. */
|
||||
const MOTIVOS = {
|
||||
estado: 'La entrada ha caducado o no se ha completado. Inténtalo otra vez.',
|
||||
codigo: 'No hemos podido completar la entrada. Inténtalo otra vez.',
|
||||
proveedor: 'El proveedor no ha respondido. Inténtalo dentro de un momento.',
|
||||
'sin-correo':
|
||||
'Esa cuenta no nos ha dado un correo verificado, y hace falta para mandarte las descargas y la factura. Entra con tu correo.',
|
||||
cancelado: 'No se ha completado la entrada.'
|
||||
} as const;
|
||||
|
||||
function aEntrar(motivo: keyof typeof MOTIVOS): never {
|
||||
redirect(303, `/entrar?fallo=${encodeURIComponent(MOTIVOS[motivo])}`);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
if (!esProveedor(params.proveedor)) aEntrar('codigo');
|
||||
|
||||
// Cuando alguien le da a «cancelar» en la pantalla del proveedor, vuelve con
|
||||
// un error en vez de con un código. No es un fallo: es una decisión.
|
||||
if (url.searchParams.get('error')) aEntrar('cancelado');
|
||||
|
||||
const resultado = await completar(params.proveedor, cookies, url, url.origin);
|
||||
if (!resultado.ok) aEntrar(resultado.motivo);
|
||||
|
||||
const usuario = await usuarioParaPerfil(params.proveedor, {
|
||||
...resultado.perfil,
|
||||
email: resultado.perfil.email as string
|
||||
});
|
||||
|
||||
const sesion = await crearSesion(usuario.id);
|
||||
establecerCookieSesion(cookies, sesion.token, sesion.expiraEn);
|
||||
|
||||
// Lo que se hubiera puesto en el carrito antes de entrar pasa a la cuenta.
|
||||
await fusionarCarrito(cookies, usuario.id);
|
||||
|
||||
redirect(303, destinoSeguro(resultado.volverA, '/cuenta'));
|
||||
};
|
||||
Loading…
Reference in new issue