Entrar con Google o con Facebook

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
dev 4 weeks ago
parent 383d0ce913
commit d3cc4e3488

@ -15,6 +15,33 @@ STRIPE_SECRET_KEY=
# stripe listen --forward-to localhost:5173/api/stripe/webhook
STRIPE_WEBHOOK_SECRET=
# --- Entrar con Google / Facebook --------------------------------------------
# Sin estas claves, los botones no se pintan y solo queda el código por correo:
# un botón de «Continuar con Google» sin configurar solo lleva a un error.
#
# La dirección de retorno tiene que estar dada de alta EXACTA en cada consola,
# y ser la misma que ORIGIN de más abajo:
#
# https://TU-DOMINIO/acceso/google/retorno
# https://TU-DOMINIO/acceso/facebook/retorno
#
# Dos avisos que cuestan una tarde si se descubren tarde:
#
# - Google NO admite direcciones IP como retorno, solo nombres de dominio (y
# http://localhost para desarrollo). Con la web en 192.168.18.171 a secas,
# esto no se puede probar: hace falta el dominio.
# - Facebook exige HTTPS y, para que lo pueda usar cualquiera y no solo los
# administradores de la app, pasar su verificación de empresa y la revisión
# del permiso `email`. Eso son días de trámite, no minutos.
#
# Google: https://console.cloud.google.com/apis/credentials
# Tipo «ID de cliente de OAuth» → «Aplicación web».
# Facebook: https://developers.facebook.com/apps → producto «Inicio de sesión».
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
# --- Correo (Resend) ---------------------------------------------------------
# Manda los códigos de acceso y los mensajes del formulario de contacto.
# Sin RESEND_API_KEY, en desarrollo el código se imprime por consola.

@ -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": {}
}
}

@ -8,6 +8,13 @@
"when": 1788780351154,
"tag": "0000_aromatic_bug",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1788820606303,
"tag": "0001_identidades",
"breakpoints": true
}
]
}

@ -196,6 +196,34 @@ test.describe('Modal de acceso', () => {
await aparte.close();
});
test('un proveedor sin configurar no se ofrece ni deja empezar', async ({ page, request }) => {
/*
* Un botón de «Continuar con Google» sin credenciales solo sirve para
* llevar a un error, así que no se pinta. Y la ruta tampoco arranca el
* baile: contesta que aquí eso no está montado.
*/
await page.goto('/entrar');
await expect(page.locator('.proveedor')).toHaveCount(0);
const respuesta = await request.get('/acceso/google', { maxRedirects: 0 });
expect(respuesta.status()).toBe(503);
});
test('una vuelta de proveedor inventada no abre sesión', async ({ request }) => {
/*
* Es la defensa del `state`: sin la cookie que se guardó al empezar, una
* vuelta fabricada por cualquiera no vale. Si valiera, bastaría con hacerte
* visitar una URL para meterte en la cuenta de otro.
*/
const respuesta = await request.get('/acceso/google/retorno?state=inventado&code=x', {
maxRedirects: 0
});
expect(respuesta.status()).toBe(303);
expect(respuesta.headers()['location']).toContain('/entrar?fallo=');
expect(respuesta.headers()['set-cookie'] ?? '').not.toContain('sesion=');
});
test('sin JavaScript, «Entrar» sigue llevando a la página', async ({ browser }) => {
const contexto = await browser.newContext({ javaScriptEnabled: false });
const pagina = await contexto.newPage();

@ -1,24 +1,31 @@
---
titulo: 'Política de cookies'
resumen: 'Dos cookies, las dos necesarias, ninguna de seguimiento.'
resumen: 'Todas necesarias, ninguna de seguimiento y ninguna de terceros.'
actualizada: 2026-09-07
orden: 4
---
Esta web usa **dos cookies, y las dos son necesarias para que funcione**. No
hay cookies de analítica, ni de publicidad, ni de terceros.
Todas las cookies de esta web son **necesarias para que funcione**. No hay
cookies de analítica, ni de publicidad, ni de terceros: las pone este dominio y
no salen de aquí.
Por eso no verás un aviso pidiéndote permiso: la normativa solo exige
consentimiento para las que no son imprescindibles, y aquí no hay ninguna.
## Las que hay
| Nombre | Para qué | Cuánto dura |
| --------- | ----------------------------------------------------------------------------------- | --------------------------------- |
| `sesion` | Mantener la sesión abierta cuando entras con tu cuenta | Hasta que cierras sesión o caduca |
| `carrito` | Recordar el carrito antes de que entres, para que no se pierda al cambiar de página | Mientras dure el carrito |
| Nombre | Para qué | Cuánto dura |
| ------------------- | ----------------------------------------------------------------------------------- | --------------------------------- |
| `sesion` | Mantener la sesión abierta cuando entras con tu cuenta | Hasta que cierras sesión o caduca |
| `carrito` | Recordar el carrito antes de que entres, para que no se pierda al cambiar de página | Mientras dure el carrito |
| `oauth_estado` | Comprobar que la vuelta de Google o Facebook es la que pediste tú | Diez minutos |
| `oauth_verificador` | La otra mitad de esa comprobación, que nunca sale de este servidor | Diez minutos |
| `oauth_vuelta` | Recordar en qué página estabas al entrar, para devolverte ahí | Diez minutos |
Las dos van marcadas como `HttpOnly` y `SameSite`, lo que significa que ningún
Las tres últimas solo aparecen si pulsas «Continuar con Google» o «Continuar
con Facebook», y se borran en cuanto vuelves, salga bien o mal.
Todas van marcadas como `HttpOnly` y `SameSite`, lo que significa que ningún
script puede leerlas y que no viajan a otros sitios.
## Lo que se guarda en tu navegador, que no son cookies
@ -34,5 +41,5 @@ Se borran vaciando los datos del sitio en tu navegador.
## Cómo quitarlas
Puedes borrar y bloquear las cookies desde tu navegador. Ten en cuenta que si
bloqueas las dos de arriba **no podrás entrar en tu cuenta ni comprar**, porque
son justamente las que sostienen esas dos cosas.
las bloqueas **no podrás entrar en tu cuenta ni comprar**, porque son
justamente las que sostienen esas dos cosas.

@ -38,19 +38,50 @@ guarda un historial: solo un contador que caduca.
## Con quién se comparten
Solo con dos empresas, y solo lo imprescindible:
Solo lo imprescindible, y solo con estas:
- **Stripe Payments Europe, Ltd.** (Irlanda) procesa los pagos. Los datos de la
tarjeta **no pasan por esta web en ningún momento**: se escriben directamente
en la pasarela de Stripe.
- **Resend** envía los correos con los códigos de acceso, y para eso recibe la
dirección de destino.
**Nada más sale de aquí.** Las tipografías se sirven desde este mismo dominio y
no desde Google, precisamente para que tu dirección IP no llegue a un tercero
solo por abrir una página. Los vídeos de YouTube no cargan nada hasta que
pulsas el botón de reproducir: hasta ese momento lo que se ve es una imagen
servida desde aquí.
- **Google Ireland Ltd.** y **Meta Platforms Ireland Ltd.**, y solo si eliges
entrar con su botón. Ver más abajo.
**Si no pulsas nada, nada más sale de aquí.** Las tipografías se sirven desde
este mismo dominio y no desde Google, precisamente para que tu dirección IP no
llegue a un tercero solo por abrir una página. Los logotipos de los botones de
acceso van dibujados en la propia página, no traídos de fuera. Y los vídeos de
YouTube no cargan nada hasta que pulsas el botón de reproducir: hasta ese
momento lo que se ve es una imagen servida desde aquí.
## Entrar con Google o con Facebook
Es opcional: existe también el código por correo, que no involucra a nadie más.
Si eliges esa vía, al pulsar el botón se te lleva al proveedor. **Es entonces
—y solo entonces— cuando ese proveedor sabe que estás entrando en este sitio.**
Nosotros no le mandamos nada antes: hasta que pulsas, no hay ninguna conexión
con ellos.
De vuelta, el proveedor nos entrega tres cosas y ninguna más: **un
identificador suyo, tu correo y tu nombre**. No se piden permisos para nada
más —ni contactos, ni publicaciones, ni la lista de amigos— y esos permisos
figuran en la pantalla que te enseña el proveedor antes de que aceptes.
Lo que se guarda aquí es ese identificador, para reconocerte la próxima vez.
**No se conserva ningún token del proveedor**: se usa una vez, en el momento de
entrar, y se descarta. Con él no podríamos hacer nada más aunque quisiéramos.
Si tu correo del proveedor coincide con el de una cuenta que ya existe aquí,
las dos se unen: es la misma persona. Para eso se exige que el proveedor
confirme que esa dirección está verificada; si no lo confirma, no se enlaza
nada y se te pide entrar con el código por correo.
Puedes revocar el acceso cuando quieras desde tu cuenta de Google
(_Seguridad → Tus conexiones a aplicaciones de terceros_) o de Facebook
(_Configuración → Apps y sitios web_). Tu cuenta aquí y tus compras siguen
siendo tuyas: entrarías con el código por correo.
## Cuánto se guardan

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

@ -3,6 +3,8 @@
import { applyAction, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import BotonesDeProveedor from './BotonesDeProveedor.svelte';
import type { Proveedor } from '$lib/types';
type Estado = {
paso: 'email' | 'codigo';
@ -22,7 +24,9 @@
*/
alEntrar = undefined as (() => void) | undefined,
/** El campo del correo se enfoca solo cuando el formulario aparece. */
enfocar = false
enfocar = false,
/** Con qué proveedores externos se puede entrar en este servidor. */
proveedores = [] as Proveedor[]
} = $props();
/*
@ -92,6 +96,13 @@
</script>
{#if estado.paso === 'email'}
<!--
Los proveedores primero: es la vía que no obliga a esperar un correo, y la
que apoya la seguridad de la cuenta en un sitio que ya tiene doble factor.
El código por correo se queda debajo, para quien no use ninguno de los dos.
-->
<BotonesDeProveedor {proveedores} {volverA} />
<p class="entradilla">
Escribe tu correo y te enviamos un código de seis dígitos. No hace falta contraseña: si es la
primera vez, la cuenta se crea sola.

@ -18,6 +18,7 @@
* forma habitual de acabar con un modal del que no se puede salir con el
* teclado.
*/
import { page } from '$app/state';
import { acceso } from '$lib/acceso.svelte';
import { heroe } from '$lib/site';
import FormularioAcceso from './FormularioAcceso.svelte';
@ -100,7 +101,12 @@
primer paso cada vez que se abre, en vez de reaparecer con el
código de la vez anterior a medio escribir.
-->
<FormularioAcceso volverA={acceso.volverA} enfocar alEntrar={() => acceso.cerrar()} />
<FormularioAcceso
volverA={acceso.volverA}
proveedores={page.data.proveedores ?? []}
enfocar
alEntrar={() => acceso.cerrar()}
/>
{/if}
<!--

@ -12,7 +12,16 @@
*/
import { relations } from 'drizzle-orm';
import { bigint, index, integer, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import {
bigint,
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex
} from 'drizzle-orm/pg-core';
const id = () =>
text('id')
@ -48,6 +57,43 @@ export const usuario = pgTable('usuario', {
ultimoAccesoEn: instante('ultimo_acceso_en')
});
/**
* Una cuenta de un proveedor externo enlazada a un usuario de aqui.
*
* El par (proveedor, sujeto) es la clave: `sujeto` es el identificador estable
* que da el proveedor —el `sub` de Google, el `id` de Facebook—, y NO el correo.
* Un correo se puede cambiar, y en algunos proveedores se puede reutilizar
* cuando se libera una cuenta; el identificador no. Enlazar por correo dejaria
* la puerta abierta a entrar en la cuenta de otro el dia que un proveedor
* reasignara una direccion.
*
* Aqui NO se guarda ningun token del proveedor. Se usa una vez, en el momento
* de entrar, para preguntar quien es, y se tira: no hacemos nada mas con esa
* cuenta —ni leer contactos, ni publicar— asi que guardarlo solo anadiria algo
* mas que puede filtrarse.
*/
export const identidad = pgTable(
'identidad',
{
id: id(),
proveedor: text('proveedor', { enum: ['google', 'facebook'] }).notNull(),
/** Identificador de la persona en ese proveedor. Estable y opaco. */
sujeto: text('sujeto').notNull(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** El correo que dio el proveedor al enlazar. Informativo. */
email: text('email'),
creadaEn: ahora('creada_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
},
(t) => [
// Una cuenta de Google no puede apuntar a dos usuarios de aqui.
uniqueIndex('identidad_proveedor_sujeto').on(t.proveedor, t.sujeto),
index('identidad_usuario').on(t.usuarioId)
]
);
/**
* Codigo de un solo uso enviado por correo.
*

@ -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);
}

@ -337,3 +337,13 @@ export interface Etiqueta {
nombre: string;
canciones: Cancion[];
}
/**
* Proveedor externo con el que se puede entrar.
*
* Vive aqui y no en `$lib/server/oauth` porque lo necesitan tambien los botones
* del formulario, que son codigo de navegador: importar algo de `$lib/server`
* desde alli esta prohibido, y con razon —una linea de mas y el secreto del
* cliente acaba en el paquete que se descarga.
*/
export type Proveedor = 'google' | 'facebook';

@ -1,6 +1,7 @@
import { leerCarrito } from '$lib/server/carrito';
import { favoritosDeUsuario } from '$lib/server/favoritos';
import { comprasDeUsuario } from '$lib/server/pedidos';
import { proveedoresDisponibles } from '$lib/server/oauth';
/**
* Estado de la cuenta disponible en todas las páginas.
@ -24,6 +25,13 @@ export async function load({ locals, cookies }) {
compras: usuario ? await comprasDeUsuario(usuario.id) : [],
// Los favoritos viajan enteros, como el carrito: son unas decenas de
// slugs y cualquier tarjeta necesita saber si el suyo esta dentro.
favoritos: usuario ? await favoritosDeUsuario(usuario.id) : []
favoritos: usuario ? await favoritosDeUsuario(usuario.id) : [],
/*
* Con qué proveedores se puede entrar en ESTE servidor. Sale de si hay
* credenciales puestas, que es información del servidor, y lo necesita el
* diálogo de acceso, que puede abrirse desde cualquier página. Un botón
* de «Entrar con Google» sin configurar solo sirve para llevar a un error.
*/
proveedores: usuario ? [] : proveedoresDisponibles()
};
}

@ -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'));
};

@ -27,10 +27,19 @@ export function load({ locals, url }) {
const email = normalizarEmail(url.searchParams.get('email') ?? '');
const reanudar = url.searchParams.get('paso') === 'codigo' && esEmail(email);
/*
* La vuelta de Google o Facebook redirige aquí con `?fallo=…` cuando algo no
* ha cuadrado. El texto lo compone la ruta de retorno, que es la que sabe qué
* ha pasado; aquí solo se enseña. Se recorta por si alguien se entretiene en
* componer una URL con un párrafo dentro.
*/
const fallo = url.searchParams.get('fallo')?.slice(0, 200) || undefined;
return {
volverA: destinoSeguro(url.searchParams.get('volverA'), '/cuenta'),
paso: reanudar ? ('codigo' as const) : ('email' as const),
email: reanudar ? email : ''
email: reanudar ? email : '',
fallo
};
}

@ -24,7 +24,8 @@
const inicial = $derived({
paso: form?.paso ?? data.paso,
email: form?.email ?? data.email,
error: form && 'error' in form ? form.error : undefined
// Un fallo al volver de un proveedor llega por la URL, no por el formulario.
error: (form && 'error' in form ? form.error : undefined) ?? data.fallo
});
const volverA = $derived(
@ -61,7 +62,7 @@
<div class="panel">
<h1>Entrar o crear cuenta</h1>
{#key inicial}
<FormularioAcceso {inicial} {volverA} />
<FormularioAcceso {inicial} {volverA} proveedores={data.proveedores ?? []} />
{/key}
</div>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.