Autenticación
Sistema de autenticación propio implementado íntegramente con Web Crypto API — sin dependencias externas. El worker emite y verifica JWTs; el frontend los almacena en localStorage y los envía como Bearer token en cada request.
Arquitectura
Frontend Worker
│ │
├─ POST /auth/signup ───────────►│ 1. Verifica que el email no exista
│ { email, password, │ 2. Hashea password (PBKDF2 + salt)
│ fullName } │ 3. Guarda en tabla users (Supabase)
│◄─ { token, user } ────────────┤ 4. Emite JWT firmado (HMAC-SHA256)
│ │
├─ POST /auth/signin ───────────►│ 1. Busca usuario por email
│ { email, password } │ 2. Verifica PBKDF2 hash
│◄─ { token, user } ────────────┤ 3. Emite JWT firmado
│
│ localStorage.setItem("auth_token", token)
│
├─ GET /api/reservations ───────►│ Lee Authorization header
│ Authorization: Bearer <tok> │ Verifica JWT con Web Crypto
│◄─ { data: [...] } ────────────┤ Extrae userId del claim `sub`Endpoints
POST /auth/signup
Crea un usuario nuevo. Devuelve un JWT listo para usar.
Body:
{
"email": "usuario@email.com",
"password": "min8caracteres",
"fullName": "Nombre Completo"
}Respuesta 201:
{
"data": {
"token": "eyJhbGc...",
"user": {
"id": "uuid",
"email": "usuario@email.com",
"fullName": "Nombre Completo"
}
}
}Errores:
409— el email ya está registrado400— campos faltantes o contraseña menor a 8 caracteres
POST /auth/signin
Autentica un usuario existente.
Body:
{
"email": "usuario@email.com",
"password": "la-contraseña"
}Respuesta 200: misma estructura que /auth/signup.
Errores:
401— email no encontrado o contraseña incorrecta
Hashing de contraseñas (PBKDF2)
Implementado en src/auth.ts usando la Web Crypto API nativa de Cloudflare Workers.
| Parámetro | Valor |
|---|---|
| Algoritmo | PBKDF2 con SHA-256 |
| Iteraciones | 100,000 |
| Salt | 16 bytes aleatorios (único por contraseña) |
| Longitud del hash | 256 bits |
Formato de almacenamiento en Supabase:
Salt y hash se concatenan y se codifican en Base64 para guardarse en la columna password_hash:
stored = base64( salt[16 bytes] + hash[32 bytes] )Por qué no bcrypt: bcrypt depende de la implementación nativa de Node.js, no disponible en el edge runtime de Cloudflare Workers. PBKDF2 con 100,000 iteraciones y SHA-256 ofrece seguridad equivalente y es nativo en Workers.
JWT (HMAC-SHA256 con Web Crypto)
Los tokens se firman con HMAC-SHA256 usando la Web Crypto API. La clave es JWT_SECRET, configurado como variable de entorno en el worker.
Claims del token
{
"sub": "uuid-del-usuario",
"email": "usuario@email.com",
"iat": 1748000000,
"exp": 1750592000
}sub— UUID del usuario (columnaclerk_user_iden Supabase, reutilizada)exp— Expiración: 30 días desde la emisióniat— Timestamp de emisión
Verificación en el worker
Cada endpoint protegido llama a verifyAuth(request, env) definida en src/auth.ts:
- Lee el header
Authorization: Bearer <token> - Verifica la firma HMAC-SHA256 con
JWT_SECRET - Comprueba que
expno haya vencido - Retorna el
sub(userId) onullsi el token es inválido/expirado
Schema de base de datos
La tabla users en Supabase necesita la columna password_hash y un constraint de unicidad en email. Ejecutar en el SQL Editor de Supabase:
-- Agregar columna para el hash de contraseña
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Unicidad en email (usar bloque DO para evitar error si ya existe)
DO $$
BEGIN
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
EXCEPTION
WHEN duplicate_object THEN NULL;
END;
$$;
ALTER TABLE ADD CONSTRAINT IF NOT EXISTSno existe en PostgreSQL. El bloqueDOconEXCEPTIONes la forma correcta de añadir un constraint de forma idempotente.
Frontend — AuthProvider
El frontend expone el estado de autenticación a través de un React Context en apps/web/lib/auth-context.tsx.
Hook useAuth()
const { user, isLoaded, isSignedIn, getToken, signIn, signUp, signOut } = useAuth();| Propiedad | Tipo | Descripción |
|---|---|---|
user | { id, email, fullName } | null | Datos del usuario autenticado |
isLoaded | boolean | false mientras se verifica el token en localStorage |
isSignedIn | boolean | Derivado de user !== null |
getToken() | () => string | null | Retorna el JWT almacenado (síncrono) |
signIn() | async (email, password) => void | Llama a /auth/signin y persiste token |
signUp() | async (email, password, fullName) => void | Llama a /auth/signup y persiste token |
signOut() | () => void | Limpia localStorage y resetea estado |
Persistencia
El token y los datos del usuario se guardan en localStorage con las claves auth_token y auth_user. Al montar el AuthProvider, se verifica que el token no haya expirado leyendo el claim exp del payload JWT.