diff --git a/docs/FORMS_API.md b/docs/FORMS_API.md index 4a4951e5a..d3ca1d2d8 100644 --- a/docs/FORMS_API.md +++ b/docs/FORMS_API.md @@ -40,7 +40,7 @@ const response = await fetch('http://localhost:8787/admin/forms', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Cookie': 'auth_token=your-session-token' // Include auth cookie + // Session cookie sent automatically with credentials: 'include' }, body: new URLSearchParams({ name: 'contact_form', @@ -57,7 +57,7 @@ const response = await fetch('http://localhost:8787/admin/forms', { ```bash curl -X POST http://localhost:8787/admin/forms \ - -H "Cookie: auth_token=your-session-token" \ + -b cookies.txt \ # or Cookie: better-auth.session_token=... -d "name=contact_form" \ -d "displayName=Contact Us" \ -d "description=Customer contact form" \ diff --git a/docs/FORMS_COMPLETE_SUMMARY.md b/docs/FORMS_COMPLETE_SUMMARY.md index 29c013eb2..f7d425aab 100644 --- a/docs/FORMS_COMPLETE_SUMMARY.md +++ b/docs/FORMS_COMPLETE_SUMMARY.md @@ -188,7 +188,7 @@ POST /api/forms/:identifier/submit - Submit form data ```typescript const response = await fetch('/admin/forms', { method: 'POST', - headers: { 'Cookie': 'auth_token=...' }, + credentials: 'include', // session cookie (better-auth.session_token) sent automatically body: new URLSearchParams({ name: 'contact_form', displayName: 'Contact Us', diff --git a/docs/FORMS_HEADLESS_FRONTEND.md b/docs/FORMS_HEADLESS_FRONTEND.md index 8e5214427..f2f6cda4c 100644 --- a/docs/FORMS_HEADLESS_FRONTEND.md +++ b/docs/FORMS_HEADLESS_FRONTEND.md @@ -693,7 +693,7 @@ If your form requires authentication: ```typescript // React example with auth const handleSubmit = async (submission: any) => { - const token = localStorage.getItem('auth_token') + // Session is in HTTP-only cookie; use credentials: 'include' so cookie is sent const response = await fetch(`${apiUrl}${formData.submitUrl}`, { method: 'POST', diff --git a/docs/ai/BREAKING_CHANGES.md b/docs/ai/BREAKING_CHANGES.md index bed4017c7..8119fc091 100644 --- a/docs/ai/BREAKING_CHANGES.md +++ b/docs/ai/BREAKING_CHANGES.md @@ -402,7 +402,7 @@ export type { User, Role, Permission, - JWTPayload, + // JWTPayload removed; session user shape from Better Auth // Cloudflare types Bindings, diff --git a/docs/ai/FEATURES_ROADMAP.md b/docs/ai/FEATURES_ROADMAP.md index 8417f6369..ed2753b53 100644 --- a/docs/ai/FEATURES_ROADMAP.md +++ b/docs/ai/FEATURES_ROADMAP.md @@ -134,7 +134,7 @@ The entire application is designed to run at the edge, providing: ## 2. Authentication & Security ### User Authentication -- **JWT-Based Auth**: Secure token management +- **Session-Based Auth (Better Auth)**: Secure session and cookie management - **Session Management**: - HTTP-only cookies - Configurable expiration @@ -682,7 +682,7 @@ sonic plugin # Manage plugins **Stage 2: Authentication (100%)** - User management -- JWT implementation +- Better Auth session implementation - Role-based access - Session handling diff --git a/docs/ai/ai-instructions.md b/docs/ai/ai-instructions.md index 813e3d5ba..047a49dd3 100644 --- a/docs/ai/ai-instructions.md +++ b/docs/ai/ai-instructions.md @@ -70,7 +70,7 @@ Based on comprehensive analysis of Strapi, Directus, and Payload CMS, SonicJS wi - Built-in auth system - Role-based access control (RBAC) - Field-level permissions - - JWT tokens with Cloudflare Access integration + - Session-based auth (Better Auth) with Cloudflare Access integration ### Medium Priority Features 1. **Content Management** diff --git a/docs/ai/claude-memory.json b/docs/ai/claude-memory.json index 34e8e4a00..d4647ba5a 100644 --- a/docs/ai/claude-memory.json +++ b/docs/ai/claude-memory.json @@ -44,7 +44,7 @@ { "id": "obs-4", "entityId": "sonicjs-ai-project", - "content": "Features complete content management with collections, versioning, media library, JWT authentication with RBAC (admin/editor/author/viewer roles), plugin architecture, and template system.", + "content": "Features complete content management with collections, versioning, media library, session-based authentication (Better Auth) with RBAC (admin/editor/viewer roles), plugin architecture, and template system.", "created": "2025-01-26T16:45:00Z" }, { diff --git a/docs/ai/core-package-api-reference.md b/docs/ai/core-package-api-reference.md index 06afb32ab..989b09acc 100644 --- a/docs/ai/core-package-api-reference.md +++ b/docs/ai/core-package-api-reference.md @@ -201,7 +201,7 @@ await bootstrap.bootstrapCorePlugins() #### requireAuth -Middleware that requires valid JWT authentication. +Middleware that requires valid session authentication (Better Auth). Reads `user` from context set by global session middleware. ```typescript import { requireAuth } from '@sonicjs-cms/core' @@ -231,22 +231,16 @@ app.use('/api/*', optionalAuth()) ### AuthManager -Static class for authentication operations. +Static class for legacy auth operations (e.g. seed-admin). Sign-in/sign-up use Better Auth; do not use `generateToken`/`verifyToken` for new features. ```typescript import { AuthManager } from '@sonicjs-cms/core' -// Hash password +// Hash password (legacy flows only) const hash = await AuthManager.hashPassword('password123') -// Verify password +// Verify password (legacy flows only) const valid = await AuthManager.verifyPassword('password123', hash) - -// Generate JWT -const token = await AuthManager.generateToken({ userId, email, role }) - -// Verify JWT -const payload = await AuthManager.verifyToken(token) ``` ### Logging Middleware diff --git a/docs/ai/plans/coverage-improvement-batch2-plan.md b/docs/ai/plans/coverage-improvement-batch2-plan.md index 8f3bba1cc..e7b3933a8 100644 --- a/docs/ai/plans/coverage-improvement-batch2-plan.md +++ b/docs/ai/plans/coverage-improvement-batch2-plan.md @@ -23,7 +23,7 @@ This plan outlines the next batch of unit tests to improve code coverage from th |------|----------| | `middleware/bootstrap.ts` | 100% | | `middleware/plugin-middleware.ts` | 100% | -| `otp-login-plugin/otp-service.ts` | 100% | +| *(otp-login-plugin removed; OTP via Better Auth)* | — | | `plugins/cache/services/cache-config.ts` | 100% | | `services/cache.ts` | 100% | | `services/collection-sync.ts` | 100% | diff --git a/docs/ai/plans/documentation-gap-analysis.md b/docs/ai/plans/documentation-gap-analysis.md index 5a023df48..23b5d099c 100644 --- a/docs/ai/plans/documentation-gap-analysis.md +++ b/docs/ai/plans/documentation-gap-analysis.md @@ -98,9 +98,8 @@ Docs │ │ ├── EasyMDE (exists) │ │ ├── TinyMCE (exists) │ │ └── Quill (exists) -│ ├── Auth Plugins -│ │ ├── OTP Login (exists) -│ │ └── Magic Link (exists) +│ ├── Auth (Better Auth) +│ │ └── Magic Link / Email OTP via config (see authentication docs) │ └── Plugin Development (exists) ├── Reference │ ├── API Reference (exists - needs expansion) diff --git a/docs/ai/plugins/plan-code-based-login.md b/docs/ai/plugins/plan-code-based-login.md index 19a3833c9..5de6c08c9 100644 --- a/docs/ai/plugins/plan-code-based-login.md +++ b/docs/ai/plugins/plan-code-based-login.md @@ -1,6 +1,10 @@ # Code-Based Login (OTP) Plugin - Implementation Plan -## Overview +**Status: Obsolete.** The OTP Login and Magic Link plugins were removed. Email OTP and magic link are now provided via **Better Auth** plugins in app config. See [docs/authentication.md](../../authentication.md) for Magic Link and Email OTP setup with `auth.extendBetterAuth`. This plan is retained for historical context only. + +--- + +## Overview (historical) Create a passwordless authentication plugin that sends a 6-digit one-time password (OTP) code via email. Users enter their email, receive a code, and enter it to authenticate. This is simpler than magic links (no clicking links) and more user-friendly for mobile devices. diff --git a/docs/ai/project-plan.md b/docs/ai/project-plan.md index 9060067fa..19831b67c 100644 --- a/docs/ai/project-plan.md +++ b/docs/ai/project-plan.md @@ -45,7 +45,7 @@ This document outlines the systematic development plan for rebuilding SonicJS as #### Stage 2 Deliverables - [x] Hono.js REST API endpoints with OpenAPI schema -- [x] JWT-based authentication middleware +- [x] Session-based authentication middleware (Better Auth) - [x] Role-based access control (RBAC) system - [x] Request validation and security middleware - [x] Admin dashboard interface (HTML/HTMX) @@ -64,7 +64,7 @@ This document outlines the systematic development plan for rebuilding SonicJS as - [x] Create Hono.js route structure and middleware - [x] Implement auto-generated REST endpoints (foundation) - [x] Set up OpenAPI schema generation -- [x] Create JWT authentication middleware +- [x] Create session auth middleware (Better Auth) - [x] Implement session and token handling - [x] Build user management system - [x] Create role and permission system @@ -396,7 +396,7 @@ Each stage should be completed and thoroughly tested before proceeding to the ne ### Stage 2 Complete ✅ (December 2024) -- **JWT Authentication**: Full token-based auth with HTTP-only cookies +- **Session Authentication (Better Auth)**: Session-based auth with HTTP-only cookies - **Role-based Access Control**: Admin, editor, author, viewer roles with middleware protection - **User Management**: Registration, login, logout, profile management with secure password hashing - **API Foundation**: Schema-driven REST endpoints with Zod validation @@ -445,7 +445,7 @@ Each stage should be completed and thoroughly tested before proceeding to the ne - **Comprehensive Documentation**: Created complete documentation covering all aspects of SonicJS AI - **Documentation Files Created**: - - `docs/authentication.md` - Complete authentication & security guide (JWT, RBAC, user management) + - `docs/authentication.md` - Complete authentication & security guide (Better Auth, RBAC, user management) - `docs/deployment.md` - Production deployment guide (Cloudflare Workers, D1, R2) - `docs/database.md` - Database operations & schema guide (Drizzle ORM, migrations, best practices) - `docs/templating.md` - Template system documentation (components, HTMX integration, patterns) diff --git a/docs/ai/www-documentation-update-plan.md b/docs/ai/www-documentation-update-plan.md index ac5245b03..756423126 100644 --- a/docs/ai/www-documentation-update-plan.md +++ b/docs/ai/www-documentation-update-plan.md @@ -27,7 +27,7 @@ The SonicJS documentation site (www folder) needs a comprehensive update to alig - Media management with R2 storage - Workflow system with scheduling - Three-tiered caching - - JWT authentication with RBAC + - Session-based authentication (Better Auth) with RBAC ### Gap Analysis 1. **Outdated Content**: Many www pages reference old API patterns or missing features @@ -204,7 +204,7 @@ The SonicJS documentation site (www folder) needs a comprehensive update to alig **Current State**: Basic auth info **Updates Needed**: - Migrate content from docs/authentication.md -- Document JWT-based authentication +- Document session-based authentication (Better Auth) - Explain token management (cookies + headers) - Show login/logout/register flows - Document password reset functionality diff --git a/docs/api-reference.md b/docs/api-reference.md index 0cb8d47b9..3a2696b85 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -15,19 +15,20 @@ http://localhost:8787/api ## Authentication -Most API endpoints require authentication. SonicJS AI uses JWT (JSON Web Tokens) for authentication with HTTP-only cookies for web clients and Bearer tokens for API clients. +Most API endpoints require authentication. SonicJS uses **Better Auth** for sign-in and sessions. Authentication is session-cookie based (HTTP-only cookie `better-auth.session_token`). -### Getting an Access Token +### Sign-in (Better Auth) -**Endpoint:** `POST /auth/login` +**Endpoint:** `POST /auth/sign-in/email` ```bash -curl -X POST "http://localhost:8787/auth/login" \ +curl -X POST "http://localhost:8787/auth/sign-in/email" \ -H "Content-Type: application/json" \ -d '{ "email": "admin@sonicjs.com", "password": "sonicjs!" - }' + }' \ + -c cookies.txt ``` **Request Body:** @@ -38,62 +39,32 @@ curl -X POST "http://localhost:8787/auth/login" \ } ``` -**Response (200 OK):** -```json -{ - "user": { - "id": "admin-user-id", - "email": "admin@sonicjs.com", - "username": "admin", - "firstName": "Admin", - "lastName": "User", - "role": "admin" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhZG1pbi11c2VyLWlkIiwiZW1haWwiOiJhZG1pbkBzb25pY2pzLmNvbSIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTczMDk0MDAwMCwiaWF0IjoxNzMwODUzNjAwfQ.xyz" -} -``` +On success, Better Auth sets a session cookie. Use `-c cookies.txt` (and `-b cookies.txt` on subsequent requests) to send the cookie with curl. In browsers, use `credentials: 'include'` so the cookie is sent automatically. -**Error Response (401 Unauthorized):** -```json -{ - "error": "Invalid email or password" -} -``` - -### Using the Token +### Using the Session -Include the token in the Authorization header for all authenticated requests: +Include the session cookie on authenticated requests. In browsers, send credentials so the cookie is attached: -```http -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +```javascript +fetch('/api/...', { credentials: 'include' }) ``` -For browser-based applications, the token is automatically stored as an HTTP-only cookie named `auth_token`. - -### Token Refresh - -**Endpoint:** `POST /auth/refresh` - -Requires existing valid authentication. +With curl, use the cookie file saved at sign-in: ```bash -curl -X POST "http://localhost:8787/auth/refresh" \ - -H "Authorization: Bearer {token}" +curl -b cookies.txt "http://localhost:8787/auth/me" ``` -**Response (200 OK):** -```json -{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` +### Session Refresh + +Session refresh is handled automatically by Better Auth. The endpoint `POST /auth/refresh` returns a message to that effect; no new token is issued. ### User Registration -**Endpoint:** `POST /auth/register` +**Endpoint:** `POST /auth/sign-up/email` (Better Auth) ```bash -curl -X POST "http://localhost:8787/auth/register" \ +curl -X POST "http://localhost:8787/auth/sign-up/email" \ -H "Content-Type: application/json" \ -d '{ "email": "newuser@example.com", @@ -109,36 +80,21 @@ curl -X POST "http://localhost:8787/auth/register" \ { "email": "newuser@example.com", "password": "securepassword123", - "username": "newuser", - "firstName": "John", - "lastName": "Doe" + "name": "John Doe" } ``` -**Response (201 Created):** -```json -{ - "user": { - "id": "uuid-generated-id", - "email": "newuser@example.com", - "username": "newuser", - "firstName": "John", - "lastName": "Doe", - "role": "viewer" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` +Optional fields (e.g. `username`, `firstName`, `lastName`) depend on your Better Auth user config. On success, Better Auth sets a session cookie. Use `-c cookies.txt` with curl to save it. See [authentication](authentication.md). ### Get Current User **Endpoint:** `GET /auth/me` -Requires authentication. +Requires authentication (session cookie). ```bash curl -X GET "http://localhost:8787/auth/me" \ - -H "Authorization: Bearer {token}" + -b cookies.txt ``` **Response (200 OK):** @@ -158,18 +114,9 @@ curl -X GET "http://localhost:8787/auth/me" \ ### Logout -**Endpoint:** `POST /auth/logout` or `GET /auth/logout` - -```bash -curl -X POST "http://localhost:8787/auth/logout" -``` +**Endpoint:** `GET /auth/logout` or `POST /auth/logout` -**Response (200 OK):** -```json -{ - "message": "Logged out successfully" -} -``` +Calls Better Auth sign-out and redirects to the login page. Include the session cookie so the session can be cleared. ## API Endpoints @@ -1199,7 +1146,7 @@ curl -X GET "http://localhost:8787/api/content?limit=10" -i ### v0.1.0 (Current) - Initial API implementation -- JWT authentication +- Session-based authentication (Better Auth) - Collections and content endpoints - Media upload and management - Three-tiered caching system diff --git a/docs/architecture.md b/docs/architecture.md index bd65ecb7e..d9f838ec6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,10 +218,8 @@ app.get('/api/data', async (c) => { ▼ ┌───────────────────────────────────────────────────────────────────┐ │ 5. Authentication Middleware (if required) │ -│ - Extract JWT token │ -│ - Check KV cache for token │ -│ - Verify token signature │ -│ - Set user context │ +│ - Read session from Better Auth (session cookie) │ +│ - Set user context (userId, email, role) │ └───────────┬───────────────────────────────────────────────────────┘ │ ▼ @@ -306,7 +304,7 @@ The middleware pipeline processes requests in a specific order to ensure proper ↓ 4. CORS (cross-origin) [Priority: 3] ↓ -5. Authentication (JWT verification) [Priority: 10] +5. Authentication (session from Better Auth) [Priority: 10] ↓ 6. Authorization (permission checks) [Priority: 11] ↓ @@ -361,52 +359,19 @@ export function bootstrapMiddleware() { ### Authentication Middleware -JWT-based authentication with KV caching: +Session-based authentication (Better Auth). A global middleware calls Better Auth `getSession` and sets `user` on context when a valid session cookie is present. `requireAuth()` checks for `c.get('user')`. ```typescript -// From /Users/lane/Dev/refs/sonicjs-ai/src/middleware/auth.ts +// packages/core/src/middleware/auth.ts export const requireAuth = () => { return async (c: Context, next: Next) => { - // Get token from header or cookie - let token = c.req.header('Authorization')?.replace('Bearer ', '') - if (!token) { - token = getCookie(c, 'auth_token') - } + const user = c.get('user') // Set by global session middleware from Better Auth - if (!token) { + if (!user) { return c.json({ error: 'Authentication required' }, 401) } - // Try KV cache first - const kv = c.env?.KV - let payload: JWTPayload | null = null - - if (kv) { - const cacheKey = `auth:${token.substring(0, 20)}` - const cached = await kv.get(cacheKey, 'json') - if (cached) { - payload = cached as JWTPayload - } - } - - // Verify token if not cached - if (!payload) { - payload = await AuthManager.verifyToken(token) - - // Cache for 5 minutes - if (payload && kv) { - await kv.put(cacheKey, JSON.stringify(payload), { - expirationTtl: 300 - }) - } - } - - if (!payload) { - return c.json({ error: 'Invalid or expired token' }, 401) - } - - c.set('user', payload) return await next() } } @@ -983,7 +948,7 @@ export async function uploadToCloudflareImages( 1. **Cache Aggressively**: Use three-tier caching 2. **Minimize Database Queries**: Batch operations when possible -3. **Use KV for Authentication**: Cache JWT verification +3. **Session in Cookie**: Better Auth manages session; no app-level token cache 4. **Optimize Middleware Order**: Fast checks first 5. **Lazy Load Plugins**: Only load when needed diff --git a/docs/authentication.md b/docs/authentication.md index fa0483df3..bb364e540 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,349 +1,201 @@ # Authentication & Security -SonicJS AI implements a comprehensive authentication and authorization system using JWT tokens, KV-based caching, and role-based access control (RBAC). This guide covers all aspects of user authentication, security, and permissions. +SonicJS uses **Better Auth** for sign-in, sign-up, and sessions, with role-based access control (RBAC) and optional extension for social login, magic link, 2FA, and other methods. + +## Extending Better Auth (custom login methods) + +You can add your own login methods (e.g. Google, magic link, 2FA) by passing `auth.extendBetterAuth` when creating the app. The function receives SonicJS’s default Better Auth options; return a merged object with your additions. + +**Example: add Google sign-in** + +```typescript +// src/index.ts +import { createSonicJSApp } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' + +const config: SonicJSConfig = { + auth: { + extendBetterAuth: (defaults) => ({ + ...defaults, + socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }, + }, + }), + }, + // ...collections, plugins, etc. +} + +export default createSonicJSApp(config) +``` + +See [Better Auth docs](https://www.better-auth.com/docs) for `socialProviders`, `magicLink`, `twoFactor`, and other options. Required env: `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` (see [deployment.md](deployment.md)). + +--- ## Table of Contents - [Overview](#overview) +- [Extending Better Auth](#extending-better-auth-custom-login-methods) - [Authentication Flow](#authentication-flow) -- [JWT Implementation](#jwt-implementation) -- [Token Caching with KV](#token-caching-with-kv) -- [Password Security](#password-security) +- [Magic Link (Better Auth plugin)](#magic-link-better-auth-plugin) +- [Email OTP (Better Auth plugin)](#email-otp-better-auth-plugin) - [Role-Based Access Control](#role-based-access-control) - [Permission System](#permission-system) - [Auth Routes & Endpoints](#auth-routes--endpoints) - [Session Management](#session-management) -- [User Invitation System](#user-invitation-system) -- [Password Reset Flow](#password-reset-flow) - [Implementing Authentication in Routes](#implementing-authentication-in-routes) - [Security Best Practices](#security-best-practices) - [Troubleshooting](#troubleshooting) ## Overview -SonicJS AI uses a modern authentication architecture built on: +SonicJS uses: -- **JWT (JSON Web Tokens)** for stateless authentication -- **Cloudflare KV** for token verification caching (5-minute TTL) -- **SHA-256 password hashing** with salt -- **RBAC (Role-Based Access Control)** for fine-grained permissions -- **HTTP-only cookies** and Bearer token support -- **Session tracking** with activity logging -- **Invitation-based user onboarding** +- **Better Auth** for sign-in, sign-up, sessions, and password hashing (session stored in DB and cookie) +- **Session cookie** `better-auth.session_token` (HTTP-only; configurable via Better Auth) +- **RBAC** — first user gets `admin`, others get `viewer` by default; registration gating via core-auth plugin +- **Optional extensions** — add magic link, email OTP, Google/GitHub, 2FA via `auth.extendBetterAuth` + +Required env: `BETTER_AUTH_SECRET` (min 32 chars), `BETTER_AUTH_URL`. See [deployment.md](deployment.md). ## Authentication Flow +Sign-in and sign-up are handled by **Better Auth** at `/auth/*`. The login and registration HTML pages submit via JavaScript to Better Auth’s API. + ### 1. User Registration -**Endpoint:** `POST /auth/register` +**Endpoint:** `POST /auth/sign-up/email` (Better Auth) ```typescript -// Request +// Request (JSON) { "email": "user@example.com", "password": "securePassword123", - "username": "johndoe", - "firstName": "John", - "lastName": "Doe" + "name": "John Doe" + // optional: username, firstName, lastName per Better Auth user fields } -// Response -{ - "user": { - "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com", - "username": "johndoe", - "firstName": "John", - "lastName": "Doe", - "role": "viewer" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} +// Response: session cookie set (better-auth.session_token), user object in body ``` **Process:** -1. Email normalized to lowercase -2. Check for duplicate email/username -3. Password hashed with SHA-256 + salt -4. User created with default role: `viewer` -5. JWT token generated (24-hour expiration) -6. HTTP-only cookie set -7. Token returned in response +1. Request goes to Better Auth; email/password validated and hashed by Better Auth +2. User created in `users` table; first user gets `admin` role via SonicJS database hooks, others get `viewer` +3. Session created; HTTP-only cookie `better-auth.session_token` set +4. Registration gating (e.g. “registration disabled”) is enforced by SonicJS before showing the register page ### 2. User Login -**Endpoint:** `POST /auth/login` +**Endpoint:** `POST /auth/sign-in/email` (Better Auth) ```typescript -// Request +// Request (JSON) { "email": "user@example.com", "password": "securePassword123" } -// Response -{ - "user": { - "id": "550e8400-e29b-41d4-a716-446655440000", - "email": "user@example.com", - "username": "johndoe", - "firstName": "John", - "lastName": "Doe", - "role": "viewer" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} +// Response: session cookie set, user/session in body ``` **Process:** -1. Email normalized to lowercase -2. User lookup with KV caching -3. Password verification with SHA-256 -4. JWT token generation -5. HTTP-only cookie set (24-hour expiration) -6. `last_login_at` timestamp updated -7. User cache invalidated to ensure fresh data +1. Better Auth verifies credentials and creates/updates session +2. Session cookie set; Hono middleware reads session and sets `c.set('user', { userId, email, role })` for routes -### 3. Token Refresh +### 3. Session Refresh -**Endpoint:** `POST /auth/refresh` +Sessions are refreshed automatically by Better Auth (e.g. `session.updateAge`). `POST /auth/refresh` returns a message that refresh is handled by Better Auth; no manual token refresh is required. -```typescript -// Headers -Authorization: Bearer +## Magic Link (Better Auth plugin) -// Response -{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -## JWT Implementation - -### Token Structure - -SonicJS AI uses JWTs with the following payload: - -```typescript -interface JWTPayload { - userId: string; // User's unique ID - email: string; // User's email (normalized) - role: string; // User's role (admin, editor, viewer) - exp: number; // Expiration timestamp (Unix) - iat: number; // Issued at timestamp (Unix) -} -``` - -### Token Generation - -```typescript -import { AuthManager } from '../middleware/auth' - -// Generate a token -const token = await AuthManager.generateToken( - userId, - email, - role -) +To add passwordless login via magic link, use Better Auth’s `magicLink` plugin in `auth.extendBetterAuth`. You must implement `sendMagicLink` (e.g. with your email service). -// Token expires in 24 hours -// exp = Math.floor(Date.now() / 1000) + (60 * 60 * 24) -``` +**1. Install any email dependency** your app uses (e.g. Resend, SendGrid). -**Implementation (`src/middleware/auth.ts`):** +**2. Extend Better Auth in your app config:** ```typescript -export class AuthManager { - static async generateToken(userId: string, email: string, role: string): Promise { - const payload: JWTPayload = { - userId, - email, - role, - exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24), // 24 hours - iat: Math.floor(Date.now() / 1000) - } - - return await sign(payload, JWT_SECRET, 'HS256') - } -} -``` - -### Token Verification - -```typescript -// Verify and decode token -const payload = await AuthManager.verifyToken(token) - -if (!payload) { - // Token invalid or expired - return c.json({ error: 'Invalid or expired token' }, 401) -} - -// Token is valid, payload contains user info -console.log(payload.userId, payload.email, payload.role) -``` - -**Implementation:** - -```typescript -static async verifyToken(token: string): Promise { - try { - const payload = await verify(token, JWT_SECRET, 'HS256') as JWTPayload - - // Check if token is expired - if (payload.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return payload - } catch (error) { - console.error('Token verification failed:', error) - return null +// src/index.ts (or wherever you call createSonicJSApp) +import { createSonicJSApp } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' +import { magicLink } from 'better-auth/plugins/magic-link' + +const config: SonicJSConfig = { + auth: { + extendBetterAuth: (defaults) => ({ + ...defaults, + plugins: [ + ...(defaults.plugins ?? []), + magicLink({ + sendMagicLink: async ({ email, url }) => { + // Send email with link: url (Better Auth handles token and callback) + await yourEmailService.send({ + to: email, + subject: 'Sign in to the app', + html: `Click to sign in: ${url}` + }) + } + }) + ] + }) } } -``` - -### Token Configuration - -```typescript -// Default configuration in src/middleware/auth.ts -const JWT_SECRET = 'your-super-secret-jwt-key-change-in-production' -// Token expiration: 24 hours -const TOKEN_EXPIRY = 60 * 60 * 24 +export default createSonicJSApp(config) ``` -**Production Configuration:** +**3. Client-side:** Use Better Auth client with `magicLinkClient` and call `signIn.magicLink({ email, callbackURL })`. See [Better Auth – Magic Link](https://www.better-auth.com/docs/plugins/magic-link). -```bash -# Set JWT_SECRET in wrangler.toml or Cloudflare dashboard -[vars] -JWT_SECRET = "your-256-bit-production-secret" -``` +**4. Database:** If the magic link plugin adds tables, run Better Auth CLI migrate/generate and add any new migrations to your project. -## Token Caching with KV +## Email OTP (Better Auth plugin) -SonicJS AI implements intelligent token verification caching using Cloudflare KV to reduce JWT verification overhead. +To add one-time password (OTP) sign-in via email, use Better Auth’s `emailOtp` plugin in `auth.extendBetterAuth` and implement `sendVerificationOTP`. -### How It Works +**1. Extend Better Auth in your app config:** ```typescript -// In requireAuth() middleware -export const requireAuth = () => { - return async (c: Context, next: Next) => { - // Get token from header or cookie - let token = c.req.header('Authorization')?.replace('Bearer ', '') - if (!token) { - token = getCookie(c, 'auth_token') - } - - // Try to get cached token verification from KV - const kv = c.env?.KV - let payload: JWTPayload | null = null - - if (kv) { - const cacheKey = `auth:${token.substring(0, 20)}` - const cached = await kv.get(cacheKey, 'json') - if (cached) { - payload = cached as JWTPayload - } - } - - // If not cached, verify token - if (!payload) { - payload = await AuthManager.verifyToken(token) - - // Cache the verified payload for 5 minutes - if (payload && kv) { - const cacheKey = `auth:${token.substring(0, 20)}` - await kv.put(cacheKey, JSON.stringify(payload), { - expirationTtl: 300 // 5 minutes +// src/index.ts +import { createSonicJSApp } from '@sonicjs-cms/core' +import type { SonicJSConfig } from '@sonicjs-cms/core' +import { emailOtp } from 'better-auth/plugins/email-otp' + +const config: SonicJSConfig = { + auth: { + extendBetterAuth: (defaults) => ({ + ...defaults, + plugins: [ + ...(defaults.plugins ?? []), + emailOtp({ + async sendVerificationOTP({ email, otp, type }) { + if (type === 'sign-in') { + await yourEmailService.send({ + to: email, + subject: 'Your sign-in code', + body: `Your code is: ${otp}` + }) + } + // Handle type === 'email-verification' or 'forget-password' if needed + } }) - } - } - - if (!payload) { - return c.json({ error: 'Invalid or expired token' }, 401) - } - - // Add user info to context - c.set('user', payload) - await next() + ] + }) } } -``` - -### Cache Strategy - -- **Cache Key:** `auth:{first-20-chars-of-token}` -- **TTL:** 5 minutes (300 seconds) -- **Cache Miss:** Verifies JWT and caches result -- **Cache Hit:** Returns cached payload (faster) -- **Invalidation:** Automatic after 5 minutes - -### Performance Benefits - -- Reduces JWT signature verification overhead -- Faster response times for authenticated requests -- Scales better under high load -- Cloudflare KV provides global edge caching - -## Password Security - -### Hashing Algorithm - -SonicJS AI uses SHA-256 with a salt for password hashing: - -```typescript -export class AuthManager { - static async hashPassword(password: string): Promise { - // In Cloudflare Workers, we use Web Crypto API - const encoder = new TextEncoder() - const data = encoder.encode(password + 'salt-change-in-production') - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - } - static async verifyPassword(password: string, hash: string): Promise { - const passwordHash = await this.hashPassword(password) - return passwordHash === hash - } -} +export default createSonicJSApp(config) ``` -### Important Notes +**2. Client-side:** Add `emailOtpClient` to the Better Auth client and use `authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' })`, then verify the OTP with the client API. See [Better Auth – Email OTP](https://www.better-auth.com/docs/plugins/email-otp). -1. **Change the salt in production** - Update `'salt-change-in-production'` to a unique, secure value -2. **SHA-256 vs bcrypt** - SHA-256 is used because bcrypt is not natively available in Cloudflare Workers -3. **Salt storage** - The salt is currently hardcoded; consider using environment variables -4. **Password requirements** - Minimum 8 characters (enforced in validation schemas) +**3. Database:** If the plugin adds tables, run the Better Auth CLI and add migrations as needed. -### Password Validation - -```typescript -// Registration schema -const registerSchema = z.object({ - email: z.string().email('Valid email is required'), - password: z.string().min(8, 'Password must be at least 8 characters'), - username: z.string().min(3, 'Username must be at least 3 characters'), - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required') -}) -``` - -### Password History - -Passwords are tracked in the `password_history` table for security: +## Password Security -```sql -CREATE TABLE password_history ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - password_hash TEXT NOT NULL, - created_at INTEGER NOT NULL -); -``` +Better Auth handles password hashing and verification for sign-in and sign-up. SonicJS keeps `AuthManager.hashPassword` and `AuthManager.verifyPassword` only for legacy flows (e.g. seed-admin); do not use them for new features. Password requirements and validation are configured in Better Auth. ## Role-Based Access Control @@ -424,7 +276,7 @@ app.post('/content', ```typescript export const requireRole = (requiredRole: string | string[]) => { return async (c: Context, next: Next) => { - const user = c.get('user') as JWTPayload + const user = c.get('user') // { userId, email, role } from Better Auth session if (!user) { return c.json({ error: 'Authentication required' }, 401) @@ -605,120 +457,46 @@ export class PermissionManager { ## Auth Routes & Endpoints -### Login Page - -**GET** `/auth/login` - -Renders the login HTML form. Supports query parameters: -- `?error=` - Display error message -- `?message=` - Display info message - -### Registration Page - -**GET** `/auth/register` - -Renders the registration HTML form. - -### Login (API) - -**POST** `/auth/login` - -```typescript -// Request body -{ - "email": "user@example.com", - "password": "password123" -} - -// Success response (200) -{ - "user": { - "id": "uuid", - "email": "user@example.com", - "username": "username", - "firstName": "John", - "lastName": "Doe", - "role": "viewer" - }, - "token": "jwt-token" -} - -// Error response (401) -{ - "error": "Invalid email or password" -} -``` +### Better Auth API (`/auth/*`) -### Login (Form) +Sign-in and sign-up are handled by Better Auth. Key endpoints: -**POST** `/auth/login/form` +- **POST** `/auth/sign-in/email` — Login with `{ email, password }` (JSON). Sets session cookie. +- **POST** `/auth/sign-up/email` — Register with `{ email, password, name }` (JSON). Sets session cookie. +- **POST** `/auth/sign-out` — Log out; clears session cookie. -Handles HTML form submissions. Returns HTMX-compatible HTML response. +See [Better Auth docs](https://www.better-auth.com/docs) for the full API (e.g. OAuth, magic link, OTP). -### Register (API) +### Login Page -**POST** `/auth/register` +**GET** `/auth/login` -```typescript -// Request body -{ - "email": "user@example.com", - "password": "password123", - "username": "johndoe", - "firstName": "John", - "lastName": "Doe" -} +Renders the login HTML form. The form submits via JavaScript to `POST /auth/sign-in/email`. Query parameters: `?error=`, `?message=`. -// Success response (201) -{ - "user": { - "id": "uuid", - "email": "user@example.com", - "username": "johndoe", - "firstName": "John", - "lastName": "Doe", - "role": "viewer" - }, - "token": "jwt-token" -} +### Registration Page -// Error response (400) -{ - "error": "User with this email or username already exists" -} -``` +**GET** `/auth/register` -### Register (Form) +Renders the registration HTML form. The form submits via JavaScript to `POST /auth/sign-up/email`. Registration can be gated (e.g. disabled except for first user). -**POST** `/auth/register/form` +### Legacy API (deprecated) -Handles HTML form submissions. First user registered gets `admin` role. +**POST** `/auth/login` and **POST** `/auth/register` return `410 Gone` with a message to use Better Auth endpoints (`/auth/sign-in/email`, `/auth/sign-up/email`) instead. ### Logout **GET** `/auth/logout` or **POST** `/auth/logout` -Clears the `auth_token` cookie and redirects to login page. - -```typescript -// GET response -// Redirects to /auth/login?message=You have been logged out successfully - -// POST response (200) -{ - "message": "Logged out successfully" -} -``` +Calls `POST /auth/sign-out` (Better Auth) and redirects to `/auth/login?message=You have been logged out successfully`. Session cookie is cleared by Better Auth. ### Get Current User **GET** `/auth/me` -Requires authentication. +Requires authentication (session cookie). Returns the current user from the database. ```typescript -// Headers -Authorization: Bearer +// Request: include session cookie (credentials: 'include' in browser) // Response (200) { @@ -734,21 +512,11 @@ Authorization: Bearer } ``` -### Refresh Token +### Session Refresh **POST** `/auth/refresh` -Requires authentication. Generates a new token with extended expiration. - -```typescript -// Headers -Authorization: Bearer - -// Response (200) -{ - "token": "new-jwt-token" -} -``` +Requires authentication. Returns a message that session refresh is handled automatically by Better Auth; no new token is returned. ### Seed Admin User (Development) @@ -775,61 +543,21 @@ Creates default admin user for testing. **Not for production use.** ## Session Management -### HTTP-Only Cookies - -SonicJS AI uses secure, HTTP-only cookies for session management: - -```typescript -setCookie(c, 'auth_token', token, { - httpOnly: true, // Cannot be accessed via JavaScript - secure: true, // HTTPS only in production - sameSite: 'Strict', // CSRF protection - maxAge: 60 * 60 * 24 // 24 hours -}) -``` - -### Session Tracking - -Sessions are tracked in the `user_sessions` table: +### Session Cookie -```sql -CREATE TABLE user_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL, - ip_address TEXT, - user_agent TEXT, - is_active INTEGER NOT NULL DEFAULT 1, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - last_used_at INTEGER -); -``` +Better Auth manages sessions and sets an HTTP-only session cookie (default name: `better-auth.session_token`). Cookie options (expiration, secure, sameSite) are configured in Better Auth. SonicJS does not set a separate `auth_token` cookie for normal sign-in/sign-up. -### Token Extraction +### Session Storage -The `requireAuth()` middleware supports multiple token sources: +Sessions are stored in the `session` table (Better Auth schema). The Hono middleware calls `auth.api.getSession({ headers })` and, if a session exists, sets `c.set('user', { userId, email, role })` and `c.set('session', session)` for route handlers. -```typescript -// 1. Authorization header (Bearer token) -Authorization: Bearer +### requireAuth() Behavior -// 2. HTTP-only cookie -Cookie: auth_token= - -// Priority: Header > Cookie -``` +`requireAuth()` reads the user from context (populated by the global session middleware). It does not read an Authorization header or a separate token cookie; authentication is session-cookie based via Better Auth. ### Session Expiration -- **Token expiration:** 24 hours from issue time -- **Cookie expiration:** 24 hours (maxAge) -- **Cache expiration:** 5 minutes (KV TTL) - -When a token expires: -1. JWT verification fails -2. User redirected to login (HTML requests) -3. 401 error returned (API requests) +Session lifetime and refresh are configured in Better Auth (e.g. `session.expiresIn`, `session.updateAge`). When the session is missing or expired, the middleware does not set `user`, and `requireAuth()` returns 401 or redirects to login. ## User Invitation System @@ -881,8 +609,7 @@ Process: 4. Hash password 5. Activate user (`is_active = 1`) 6. Clear `invitation_token` -7. Auto-login with JWT token -8. Redirect to admin dashboard +7. Auto-login (session set) and redirect to admin dashboard ### Invitation Expiration @@ -1295,54 +1022,25 @@ export { contentRoutes } ## Security Best Practices -### 1. Production JWT Secret +### 1. Production Better Auth Secret -**Never use default JWT secret in production.** +**Never use a weak or default secret in production.** ```bash -# Generate a secure random secret +# Generate a secure random secret (min 32 characters) openssl rand -base64 32 -# Add to wrangler.toml -[vars] -JWT_SECRET = "your-secure-random-256-bit-secret" -``` - -### 2. Change Password Salt - -Update the salt in `src/middleware/auth.ts`: - -```typescript -// BEFORE (insecure) -const data = encoder.encode(password + 'salt-change-in-production') - -// AFTER (secure) -const SALT = c.env.PASSWORD_SALT || 'your-unique-production-salt' -const data = encoder.encode(password + SALT) -``` - -Better yet, use environment-specific salts: - -```bash -# wrangler.toml -[vars] -PASSWORD_SALT = "your-unique-production-salt-value" +# Set as Wrangler secret +openssl rand -base64 32 | wrangler secret put BETTER_AUTH_SECRET --env production ``` -### 3. HTTPS Only +Also set `BETTER_AUTH_URL` to your app’s public URL (e.g. `https://your-app.com`). See [deployment.md](deployment.md). -Always use HTTPS in production: +### 2. HTTPS Only -```typescript -setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', // true in production - sameSite: 'Strict', - maxAge: 60 * 60 * 24 -}) -``` +Use HTTPS in production so the session cookie is sent only over secure connections. Better Auth can be configured with `advanced.useSecureCookies` for production. -### 4. Rate Limiting +### 3. Rate Limiting Implement rate limiting for auth endpoints: @@ -1355,7 +1053,7 @@ const RATE_LIMITS = { } ``` -### 5. Password Requirements +### 4. Password Requirements Enforce strong passwords: @@ -1368,7 +1066,7 @@ const strongPasswordSchema = z.string() .regex(/[^A-Za-z0-9]/, 'Password must contain special character') ``` -### 6. Email Verification +### 5. Email Verification Implement email verification: @@ -1386,7 +1084,7 @@ await db.prepare(` // Send verification email with token ``` -### 7. Two-Factor Authentication (2FA) +### 6. Two-Factor Authentication (2FA) Enable 2FA for sensitive accounts: @@ -1412,7 +1110,7 @@ await logActivity( ) ``` -### 9. Secure Headers +### 8. Secure Headers Set security headers: @@ -1461,7 +1159,7 @@ app.use('*', cors({ })) ``` -### 12. Input Validation +### 10. Input Validation Always validate and sanitize input: @@ -1487,23 +1185,14 @@ app.put('/user/:id', ## Troubleshooting -### Token Validation Errors +### Session / Authentication Required -**Problem:** `Invalid or expired token` +**Problem:** `Authentication required` or `Invalid or expired token` **Solutions:** -1. Check JWT_SECRET matches between token generation and verification -2. Verify token hasn't expired (check `exp` claim) -3. Ensure token is properly formatted (Bearer ) -4. Check KV cache for stale data - -```typescript -// Debug token -const parts = token.split('.') -const payload = JSON.parse(atob(parts[1])) -console.log('Token payload:', payload) -console.log('Expired?', payload.exp < Date.now() / 1000) -``` +1. Ensure the session cookie (`better-auth.session_token`) is sent with the request (browser: `credentials: 'include'`; same origin so cookies are sent by default). +2. Check that `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are set correctly (same secret and URL used when the session was created). +3. If the session has expired, the user must sign in again via `/auth/sign-in/email` or your configured method (e.g. magic link, OTP). ### Permission Denied Errors @@ -1539,71 +1228,22 @@ PermissionManager.clearAllCache() ### Cookie Not Set -**Problem:** Auth cookie not being sent/received - -**Solutions:** -1. Verify `secure` flag matches protocol (HTTP vs HTTPS) -2. Check `sameSite` setting -3. Ensure domain matches -4. Check browser console for cookie errors - -```typescript -// Development (HTTP) -setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: false, // false for localhost HTTP - sameSite: 'Lax', // Lax for development - maxAge: 60 * 60 * 24 -}) - -// Production (HTTPS) -setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: true, // true for HTTPS - sameSite: 'Strict', - maxAge: 60 * 60 * 24 -}) -``` - -### KV Cache Issues - -**Problem:** Stale cached data +**Problem:** Session cookie not being sent or received **Solutions:** -1. Wait for cache expiration (5 minutes) -2. Manually clear KV keys -3. Check KV namespace binding - -```typescript -// Clear cached token verification -const cacheKey = `auth:${token.substring(0, 20)}` -await kv.delete(cacheKey) - -// Clear user cache -await cache.delete(`user:${userId}`) -await cache.delete(`user:email:${email}`) -``` +1. Ensure `BETTER_AUTH_URL` matches the URL the user is visiting (same origin so cookies are sent). +2. In development (HTTP), Better Auth may set cookies with `secure: false`; in production use HTTPS and `useSecureCookies` if needed. +3. Check `sameSite` and domain; cross-site requests require correct CORS and cookie settings. +4. Check browser DevTools → Application → Cookies for the session cookie. ### Password Verification Failed -**Problem:** Valid password rejected +**Problem:** Valid password rejected at sign-in **Solutions:** -1. Check salt matches between hash and verify -2. Verify password_hash in database -3. Check for encoding issues -4. Ensure consistent salt usage - -```typescript -// Test password hashing -const password = 'test123' -const hash1 = await AuthManager.hashPassword(password) -const hash2 = await AuthManager.hashPassword(password) -console.log('Hashes match?', hash1 === hash2) // Should be true - -const valid = await AuthManager.verifyPassword(password, hash1) -console.log('Verification works?', valid) // Should be true -``` +1. Sign-in is handled by Better Auth; ensure the request is sent to `POST /auth/sign-in/email` with correct `email` and `password`. +2. Check that the user exists in the `users` table and that Better Auth’s password verification (and any custom adapter) is correct. +3. For legacy flows that use `AuthManager.verifyPassword` (e.g. seed-admin), ensure the stored hash was produced by the same method. ### Database Connection Issues diff --git a/docs/deployment.md b/docs/deployment.md index 62f3875f6..4e97f17f2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -473,14 +473,18 @@ cache:collection-content:blog-posts:limit:50 - Sensitive data (passwords, API keys) - Encrypted at rest - Only available at runtime - - Examples: JWT_SECRET, API keys + - Examples: BETTER_AUTH_SECRET, API keys ### Required Secrets for Production ```bash -# JWT Secret for authentication -# Generate a secure random string -openssl rand -base64 32 | wrangler secret put JWT_SECRET --env production +# Better Auth: session signing and base URL (required for authentication) +# BETTER_AUTH_SECRET: min 32 characters; used to sign session cookies +openssl rand -base64 32 | wrangler secret put BETTER_AUTH_SECRET --env production + +# BETTER_AUTH_URL: public URL of your app (e.g. https://your-app.com or https://your-app.workers.dev) +# For local dev, use http://localhost:8787 in .dev.vars +wrangler secret put BETTER_AUTH_URL --env production # Admin password for initial setup (optional) echo "your-secure-admin-password" | wrangler secret put ADMIN_PASSWORD --env production @@ -534,7 +538,7 @@ export default { const environment = env.ENVIRONMENT // "production" // Secrets (encrypted) - const jwtSecret = env.JWT_SECRET + const authSecret = env.BETTER_AUTH_SECRET // Bindings const db = env.DB @@ -811,7 +815,7 @@ Use this checklist before going live: - [ ] Production R2 bucket created - [ ] Production KV namespace created - [ ] All bindings configured in wrangler.toml -- [ ] Secrets uploaded (JWT_SECRET, etc.) +- [ ] Secrets uploaded (BETTER_AUTH_SECRET, BETTER_AUTH_URL, etc.) - [ ] Custom domain added and DNS configured - [ ] SSL certificate active and valid @@ -835,7 +839,7 @@ Use this checklist before going live: ### Security - [ ] HTTPS enforced (no HTTP access) -- [ ] Strong JWT secret configured +- [ ] Strong Better Auth secret configured (BETTER_AUTH_SECRET) - [ ] CORS properly configured - [ ] Rate limiting enabled (if applicable) - [ ] Security headers configured @@ -1424,14 +1428,15 @@ wrangler d1 execute sonicjs-ai --env production --command="SELECT name FROM sqli ```bash # Symptom -Error: Uncaught ReferenceError: JWT_SECRET is not defined +Error: Uncaught ReferenceError: BETTER_AUTH_SECRET is not defined # Solution # List secrets wrangler secret list --env production -# Add missing secret -echo "your-secret-value" | wrangler secret put JWT_SECRET --env production +# Add required auth secrets +openssl rand -base64 32 | wrangler secret put BETTER_AUTH_SECRET --env production +wrangler secret put BETTER_AUTH_URL --env production ``` #### Issue: R2 Bucket Access Denied diff --git a/docs/getting-started.md b/docs/getting-started.md index 715c9e556..fe43dd76d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -189,7 +189,7 @@ sonicjs-ai/ ├── src/ │ ├── index.ts # Application entry point │ ├── middleware/ # Request middleware -│ │ ├── auth.ts # JWT authentication +│ │ ├── auth.ts # Session auth (Better Auth) + requireAuth/requireRole │ │ ├── bootstrap.ts # App initialization │ │ ├── logging.ts # Request/security logging │ │ ├── performance.ts # Cache headers & optimization @@ -374,11 +374,11 @@ Request → Memory → KV → Database → Populate KV → Populate Memory → R ### 5. Authentication & Authorization -**JWT-based Authentication:** +**Session-based Authentication (Better Auth):** -- 24-hour token expiration -- Cookie and header support -- KV-based token caching (5-min TTL) +- Sign-in/sign-up at `/auth/sign-in/email`, `/auth/sign-up/email` +- HTTP-only session cookie (`better-auth.session_token`) +- Optional: magic link, email OTP, OAuth via `auth.extendBetterAuth` **Role-Based Access Control (RBAC):** diff --git a/docs/issues/type-check-failures.md b/docs/issues/type-check-failures.md index 6080e3984..672f6f24d 100644 --- a/docs/issues/type-check-failures.md +++ b/docs/issues/type-check-failures.md @@ -15,7 +15,6 @@ ## Outstanding compiler errors (abbreviated) - **Plugin config** (`src/plugins/config/plugin-config.ts`): calls to `z.object` helpers are missing default values, so the generated schema functions now expect more arguments than provided. -- **Email + OTP plugins** (`src/plugins/core-plugins/*/index.ts`): async template helpers return `Promise` but the plugin manifest expects plain `HtmlEscapedString`. The plugin metadata also assumes the `author.name` field is optional even though the manifest definition makes it required. - **Plugin registry exports** (`src/plugins/core-plugins/index.ts`): the registry file no longer exports `CORE_PLUGIN_IDS` / `PLUGIN_REGISTRY`. Downstream imports need to switch to the new `PluginRegistryImpl` API. - **Media plugin + admin routes**: multiple `id`, `db`, and `mediaGridHTML` identifiers are used without prior declaration, indicating unfinished refactors in `src/plugins/core-plugins/media/index.ts` and `src/routes/admin-collections.ts` / `admin-media.ts`. - **Workflow + SDK builder**: references to `_workflowSchemas`, `context`, `isAuthor`, and `basePath` were removed elsewhere but still consumed inside the workflow plugin and SDK builder services. @@ -24,12 +23,13 @@ See the `npm run type-check` output in this workspace run (timestamped Feb 25, 2025) for the full 33-error log. +**Note:** The former OTP Login and Magic Link plugins were removed; magic link and email OTP are now provided via Better Auth plugins in app config (see [authentication.md](../authentication.md)). The "Email + OTP plugins" type-check category above no longer applies. + ## Next steps 1. **Stabilize plugin/config schemas** – update the Zod builders so helper functions always receive the required arguments or provide defaults. -2. **Fix plugin manifests** – ensure async template renderers await their Promise results, and make the author metadata optional-safe. -3. **Align with the new registry** – update imports to use the currently exported registry helpers and extend types where necessary. -4. **Clean up dangling identifiers** – audit the admin routes + media/plugin code paths to reintroduce the missing variables (`db`, `mediaGridHTML`, `basePath`, etc.) or remove unused logic. -5. **Tighten schemas** – explicitly type `validatedData`, define `FilterBarData`, and add concrete parameter types to template helpers. -6. **Add regression tests** – once `tsc` passes, add targeted unit specs under `packages/core/src/__tests__` plus a Playwright flow to verify plugin management still works end-to-end. +2. **Align with the new registry** – update imports to use the currently exported registry helpers and extend types where necessary. +3. **Clean up dangling identifiers** – audit the admin routes + media/plugin code paths to reintroduce the missing variables (`db`, `mediaGridHTML`, `basePath`, etc.) or remove unused logic. +4. **Tighten schemas** – explicitly type `validatedData`, define `FilterBarData`, and add concrete parameter types to template helpers. +5. **Add regression tests** – once `tsc` passes, add targeted unit specs under `packages/core/src/__tests__` plus a Playwright flow to verify plugin management still works end-to-end. Tracking issue: https://github.com/lane711/sonicjs/issues/330 diff --git a/docs/routing-middleware.md b/docs/routing-middleware.md index 64119ff97..e60348fa9 100644 --- a/docs/routing-middleware.md +++ b/docs/routing-middleware.md @@ -77,34 +77,25 @@ Response ## Authentication Middleware -The authentication system uses JWT tokens stored in HTTP-only cookies. +The authentication system uses **Better Auth** for sign-in, sign-up, and sessions. Sessions are stored in an HTTP-only cookie (`better-auth.session_token`). A global middleware populates `c.set('user', { userId, email, role })` from the session. ### File Location -`/Users/lane/Dev/refs/sonicjs-ai/src/middleware/auth.ts` +`packages/core/src/middleware/auth.ts` -### Authentication Manager +### AuthManager (legacy / seed-admin only) ```typescript import { AuthManager } from '../middleware/auth' -// Generate JWT token -const token = await AuthManager.generateToken( - userId, - email, - role -) - -// Verify JWT token -const payload = await AuthManager.verifyToken(token) -// Returns: { userId, email, role, exp, iat } or null - -// Hash password +// Hash password (used only for legacy flows, e.g. seed-admin) const hash = await AuthManager.hashPassword(password) // Verify password const isValid = await AuthManager.verifyPassword(password, hash) ``` +Sign-in and sign-up use Better Auth; do not use `AuthManager.generateToken` or `AuthManager.verifyToken` for new features. + ### requireAuth Middleware Requires valid authentication to access a route. @@ -115,7 +106,7 @@ import { requireAuth } from '../middleware/auth' // Protect a route app.get('/protected', requireAuth(), async (c) => { const user = c.get('user') - // user contains: { userId, email, role, exp, iat } + // user contains: { userId, email, role } from Better Auth session return c.json({ message: 'Welcome!', user }) }) @@ -123,12 +114,9 @@ app.get('/protected', requireAuth(), async (c) => { **How it works:** -1. Checks for token in `Authorization` header (Bearer token) -2. Falls back to `auth_token` cookie if no header present -3. Verifies token with KV cache (5-minute TTL) -4. Falls back to JWT verification if not cached -5. Sets `user` object on context for downstream use -6. Returns 401 error or redirects to login if invalid +1. Global session middleware calls Better Auth `getSession` and sets `user` on context when a valid session cookie is present. +2. `requireAuth()` checks for `c.get('user')`; if missing, returns 401 or redirects to login. +3. No separate Bearer token or KV cache; authentication is session-cookie based. **Example Usage:** @@ -1494,7 +1482,7 @@ export default app This documentation provides a comprehensive guide to routing and middleware in SonicJS AI: 1. **Middleware Pipeline**: Ordered execution from bootstrap through to route handlers -2. **Authentication**: JWT-based auth with requireAuth, requireRole, and optionalAuth +2. **Authentication**: Session-based auth (Better Auth) with requireAuth, requireRole, and optionalAuth 3. **Permissions**: Fine-grained permission system with requirePermission and requireAnyPermission 4. **Bootstrap**: One-time system initialization on worker startup 5. **Logging**: Request, security, and performance logging diff --git a/package-lock.json b/package-lock.json index 47e34c5a4..29270ad3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sonicjs", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sonicjs", - "version": "2.7.0", + "version": "2.8.0", "workspaces": [ "packages/*", "www", @@ -18,6 +18,7 @@ "@hono/zod-validator": "^0.7.0", "@libsql/client": "^0.15.9", "@sonicjs-cms/core": "^2.0.1", + "better-auth": "^1.4.18", "clsx": "^2.1.1", "drizzle-kit": "^0.31.2", "drizzle-orm": "^0.44.7", @@ -70,7 +71,6 @@ "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -173,6 +173,7 @@ "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.8.0.tgz", "integrity": "sha512-Hb4BkGNnvgCj3F9XzqjiFTpA5IGkjOXwGAOV13qtc27l2qNF8X9rzSp1H5hu8XewlC0DzYtQtZZIOYzRZDyuXg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -220,6 +221,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.42.0.tgz", "integrity": "sha512-JLyyG7bb7XOda+w/sp8ch7rEVy6LnWs3qtxr6VJJ2XIINqGsY6U+0L3aJ6QFliBRNUeEAr2QBDxSm8u9Sal5uA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -235,6 +237,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.42.0.tgz", "integrity": "sha512-SkCrvtZpdSWjNq9NGu/TtOg4TbzRuUToXlQqV6lLePa2s/WQlEyFw7QYjrz4itprWG9ASuH+StDlq7n49F2sBA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -250,6 +253,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.42.0.tgz", "integrity": "sha512-6iiFbm2tRn6B2OqFv9XDTcw5LdWPudiJWIbRk+fsTX+hkPrPm4e1/SbU+lEYBciPoaTShLkDbRge4UePEyCPMQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 14.0.0" } @@ -259,6 +263,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.42.0.tgz", "integrity": "sha512-iEokmw2k6FBa8g/TT7ClyEriaP/FUEmz3iczRoCklEHWSgoABMkaeYrxRXrA2yx76AN+gyZoC8FX0iCJ55dsOg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -274,6 +279,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.42.0.tgz", "integrity": "sha512-ivVniRqX2ARd+jGvRHTxpWeOtO9VT+rK+OmiuRgkSunoTyxk0vjeDO7QkU7+lzBOXiYgakNjkZrBtIpW9c+muw==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -289,6 +295,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.42.0.tgz", "integrity": "sha512-9+BIw6rerUfA+eLMIS2lF4mgoeBGTCIHiqb35PLn3699Rm3CaJXz03hChdwAWcA6SwGw0haYXYJa7LF0xI6EpA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -320,6 +327,7 @@ "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.42.0.tgz", "integrity": "sha512-MBkjRymf4BT6VOvMpJlg6kq8K+PkH9q+N+K4YMNdzTXlL40YwOa1wIWQ5LxP/Jhlz64kW5g9/oaMWY06Sy9dcw==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -335,6 +343,7 @@ "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.42.0.tgz", "integrity": "sha512-kmLs7YfjT4cpr4FnhhRmnoSX4psh9KYZ9NAiWt/YcUV33m0B/Os5L4QId30zVXkOqAPAEpV5VbDPWep+/aoJdQ==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -350,6 +359,7 @@ "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.42.0.tgz", "integrity": "sha512-U5yZ8+Jj+A4ZC0IMfElpPcddQ9NCoawD1dKyWmjHP49nzN2Z4284IFVMAJWR6fq/0ddGf4OMjjYO9cnF8L+5tw==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0", "@algolia/requester-browser-xhr": "5.42.0", @@ -365,6 +375,7 @@ "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.42.0.tgz", "integrity": "sha512-EbuxgteaYBlKgc2Fs3JzoPIKAIaevAIwmv1F+fakaEXeibG4pkmVNsyTUjpOZIgJ1kXeqNvDrcjRb6g3vYBJ9A==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0" }, @@ -377,6 +388,7 @@ "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.42.0.tgz", "integrity": "sha512-4vnFvY5Q8QZL9eDNkywFLsk/eQCRBXCBpE8HWs8iUsFNHYoamiOxAeYMin0W/nszQj6abc+jNxMChHmejO+ftQ==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0" }, @@ -389,6 +401,7 @@ "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.42.0.tgz", "integrity": "sha512-gkLNpU+b1pCIwk1hKTJz2NWQPT8gsfGhQasnZ5QVv4jd79fKRL/1ikd86P0AzuIQs9tbbhlMwxsSTyJmlq502w==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.42.0" }, @@ -8008,7 +8021,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -8257,6 +8269,55 @@ "node": ">=18" } }, + "node_modules/@better-auth/core": { + "version": "1.4.18", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.18.tgz", + "integrity": "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "better-call": "1.1.8", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/core/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.4.18", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.18.tgz", + "integrity": "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==", + "dependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.4.18" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz", @@ -8891,6 +8952,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=12" } @@ -8907,6 +8969,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -8923,6 +8986,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -8939,6 +9003,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=12" } @@ -8955,6 +9020,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -8971,6 +9037,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=12" } @@ -8987,6 +9054,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -9003,6 +9071,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -9019,6 +9088,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9035,6 +9105,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9051,6 +9122,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9067,6 +9139,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9083,6 +9156,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9099,6 +9173,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9115,6 +9190,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9131,6 +9207,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9147,6 +9224,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=12" } @@ -9179,6 +9257,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -9211,6 +9290,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=12" } @@ -9243,6 +9323,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=12" } @@ -9259,6 +9340,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -9275,6 +9357,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -9291,6 +9374,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=12" } @@ -10197,7 +10281,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", @@ -10368,7 +10451,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-3.1.1.tgz", "integrity": "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==", "license": "MIT", - "peer": true, "dependencies": { "@mdx-js/mdx": "^3.0.0", "source-map": "^0.7.0" @@ -10428,7 +10510,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -10659,7 +10740,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -11343,7 +11423,6 @@ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.56.1" }, @@ -13288,7 +13367,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -13615,7 +13693,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -13635,7 +13713,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -13736,7 +13814,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -13827,7 +13904,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -14526,7 +14602,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -14856,7 +14931,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -14973,6 +15048,159 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-auth": { + "version": "1.4.18", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.18.tgz", + "integrity": "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.4.18", + "@better-auth/telemetry": "1.4.18", + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0", + "better-call": "1.1.8", + "defu": "^6.1.4", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/better-call": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", + "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.0", + "@better-fetch/fetch": "^1.1.4", + "rou3": "^0.7.10", + "set-cookie-parser": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -15072,7 +15300,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -15918,6 +16145,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -16453,7 +16686,6 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -16844,7 +17076,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -16957,7 +17189,6 @@ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -17035,7 +17266,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -17221,7 +17451,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -17695,7 +17924,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -18783,7 +19012,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -19570,6 +19798,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -19714,6 +19951,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -21934,6 +22180,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -22813,7 +23074,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -22829,7 +23089,6 @@ "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", "license": "MIT", - "peer": true, "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", @@ -23129,7 +23388,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -23139,7 +23397,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -23507,7 +23764,7 @@ "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -23545,6 +23802,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -23750,6 +24013,12 @@ "node": ">= 18" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -24037,7 +24306,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -24156,7 +24425,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/statuses": { @@ -24172,7 +24441,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stdin-discarder": { @@ -24697,8 +24966,7 @@ "version": "4.1.16", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -24764,21 +25032,21 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -25515,7 +25783,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -26107,7 +26374,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -26187,7 +26453,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -26474,7 +26739,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -26573,7 +26837,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -27085,9 +27348,8 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.7.tgz", "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.7", "@vitest/mocker": "4.0.7", @@ -27589,7 +27851,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.7.tgz", "integrity": "sha512-jGRG6HghnJDjljdjYIoVzX17S6uCVCBRFnsgdLGJ6CaxfPh8kzUKe/2n533y4O/aeZ/sIr7q7GbuEbeGDsWv4Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -27607,7 +27869,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.7.tgz", "integrity": "sha512-OsDwLS7WnpuNslOV6bJkXVYVV/6RSc4eeVxV7h9wxQPNxnjRvTTrIikfwCbMyl8XJmW6oOccBj2Q07YwZtQcCw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.0.7", @@ -27634,7 +27896,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz", "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.0.3" @@ -27647,7 +27909,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.7.tgz", "integrity": "sha512-orU1lsu4PxLEcDWfjVCNGIedOSF/YtZ+XMrd1PZb90E68khWCNzD8y1dtxtgd0hyBIQk8XggteKN/38VQLvzuw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.0.7", @@ -27661,7 +27923,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.7.tgz", "integrity": "sha512-xJL+Nkw0OjaUXXQf13B8iKK5pI9QVtN9uOtzNHYuG/o/B7fIEg0DQ+xOe0/RcqwDEI15rud1k7y5xznBKGUXAA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.7", @@ -27676,7 +27938,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.7.tgz", "integrity": "sha512-FW4X8hzIEn4z+HublB4hBF/FhCVaXfIHm8sUfvlznrcy1MQG7VooBgZPMtVCGZtHi0yl3KESaXTqsKh16d8cFg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -27686,7 +27948,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.7.tgz", "integrity": "sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.7", @@ -27700,7 +27962,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -27710,7 +27972,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -27752,14 +28014,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/vitest/node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -27769,9 +28031,8 @@ "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -27986,7 +28247,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -28015,7 +28276,6 @@ "integrity": "sha512-ov6Pt4k6d/ALfJja/EIHohT9IrY/f6GAa0arWEPat2qekp78xHbVM7jSxNWAMbaE7ZmnQQIFEGD1ZhAWZmQKIg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -28035,7 +28295,6 @@ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.59.1.tgz", "integrity": "sha512-5DddGSNxHd6dOjREWTDQdovQlZ1Lh80NNRXZFQ4/CrK3fNyVIBj9tqCs9pmXMNrKQ/AnKNeYzEs/l1kr8rHhOg==", "license": "MIT OR Apache-2.0", - "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.9.0", @@ -28816,7 +29075,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -29000,7 +29258,7 @@ }, "packages/create-app": { "name": "create-sonicjs", - "version": "2.7.0", + "version": "2.8.0", "license": "MIT", "dependencies": { "execa": "^9.6.0", diff --git a/package.json b/package.json index b85fdc5d6..e50fc3db2 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@hono/zod-validator": "^0.7.0", "@libsql/client": "^0.15.9", "@sonicjs-cms/core": "^2.0.1", + "better-auth": "^1.4.18", "clsx": "^2.1.1", "drizzle-kit": "^0.31.2", "drizzle-orm": "^0.44.7", diff --git a/packages/core/README.md b/packages/core/README.md index 08048dfbf..2bb50c683 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -29,7 +29,7 @@ This is the recommended way to get started with SonicJS. It sets up everything y - 🔌 **Plugin System**: Extensible architecture with hooks and middleware - ⚡ **Three-Tier Caching**: Memory, KV, and database layers for optimal performance - 🎨 **Admin Interface**: Beautiful glass morphism design system -- 🔐 **Authentication**: JWT-based auth with role-based permissions +- 🔐 **Authentication**: Better Auth session-based auth with role-based permissions - 📝 **Content Management**: Dynamic collections with versioning and workflows - 🖼️ **Media Management**: R2 storage with automatic optimization - 🌐 **REST API**: Auto-generated endpoints for all collections diff --git a/packages/core/migrations/032_better_auth.sql b/packages/core/migrations/032_better_auth.sql new file mode 100644 index 000000000..e3140db8d --- /dev/null +++ b/packages/core/migrations/032_better_auth.sql @@ -0,0 +1,55 @@ +-- Better Auth: Add name column to users and create session, account, verification tables +-- Required by better-auth (https://better-auth.com/docs/concepts/database) + +-- Add name column for Better Auth (required for registration) +-- Existing rows: set name from first_name + ' ' + last_name in application or leave null +ALTER TABLE users ADD COLUMN name TEXT; + +-- Better Auth session table (cookie-based sessions) +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id); +CREATE INDEX IF NOT EXISTS idx_session_token ON session(token); +CREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at); + +-- Better Auth account table (credentials + OAuth providers) +CREATE TABLE IF NOT EXISTS account ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + access_token_expires_at INTEGER, + refresh_token_expires_at INTEGER, + scope TEXT, + id_token TEXT, + password TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_user_id ON account(user_id); +CREATE INDEX IF NOT EXISTS idx_account_provider ON account(provider_id, account_id); + +-- Better Auth verification table (email verification, password reset, etc.) +CREATE TABLE IF NOT EXISTS verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_verification_identifier ON verification(identifier); +CREATE INDEX IF NOT EXISTS idx_verification_expires_at ON verification(expires_at); diff --git a/packages/core/migrations/033_drop_otp_and_magic_link_tables.sql b/packages/core/migrations/033_drop_otp_and_magic_link_tables.sql new file mode 100644 index 000000000..7f935df18 --- /dev/null +++ b/packages/core/migrations/033_drop_otp_and_magic_link_tables.sql @@ -0,0 +1,12 @@ +-- Drop OTP and Magic Link plugin tables +-- Migration: 033_drop_otp_and_magic_link_tables +-- Description: Remove magic_links and otp_codes tables (plugins removed in favor of Better Auth extendBetterAuth) + +-- Drop magic link auth table and indexes (indexes are dropped with the table in SQLite) +DROP TABLE IF EXISTS magic_links; + +-- Drop OTP login table and indexes +DROP TABLE IF EXISTS otp_codes; + +-- Remove plugin registry entries so they no longer appear in admin +DELETE FROM plugins WHERE id IN ('magic-link-auth', 'otp-login'); diff --git a/packages/core/package.json b/packages/core/package.json index dfe1100bc..e06b72d61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -102,6 +102,8 @@ "zod": "^3.0.0 || ^4.0.0" }, "dependencies": { + "better-auth": "^1.4.18", + "drizzle-orm": "^0.44.7", "drizzle-zod": "^0.8.3", "highlight.js": "^11.11.1", "marked": "^16.4.1", @@ -113,7 +115,6 @@ "@types/node": "^24.9.2", "@typescript-eslint/eslint-plugin": "^8.50.0", "@typescript-eslint/parser": "^8.50.0", - "drizzle-orm": "^0.44.7", "eslint": "^9.39.2", "glob": "^10.5.0", "hono": "^4.11.7", diff --git a/packages/core/src/__tests__/middleware/auth.test.ts b/packages/core/src/__tests__/middleware/auth.test.ts index 608b216b1..c3351ac3e 100644 --- a/packages/core/src/__tests__/middleware/auth.test.ts +++ b/packages/core/src/__tests__/middleware/auth.test.ts @@ -1,65 +1,21 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { AuthManager, requireAuth, requireRole, optionalAuth } from '../../middleware/auth' -import { Context, Next } from 'hono' +import type { Context, Next } from 'hono' describe('AuthManager', () => { describe('generateToken', () => { - it('should generate a valid JWT token', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - - expect(token).toBeTruthy() - expect(typeof token).toBe('string') - expect(token.split('.')).toHaveLength(3) // JWT has 3 parts: header.payload.signature - }) - - it('should generate unique tokens for different users', async () => { - const token1 = await AuthManager.generateToken('user-1', 'user1@example.com', 'user') - const token2 = await AuthManager.generateToken('user-2', 'user2@example.com', 'user') - - expect(token1).not.toBe(token2) + it('should throw (deprecated; use Better Auth)', async () => { + await expect( + AuthManager.generateToken('user-123', 'test@example.com', 'admin') + ).rejects.toThrow('JWT generation is deprecated') }) }) describe('verifyToken', () => { - it('should verify a valid token', async () => { - const userId = 'user-123' - const email = 'test@example.com' - const role = 'admin' - - const token = await AuthManager.generateToken(userId, email, role) - const payload = await AuthManager.verifyToken(token) - - expect(payload).toBeTruthy() - expect(payload?.userId).toBe(userId) - expect(payload?.email).toBe(email) - expect(payload?.role).toBe(role) - }) - - it('should return null for invalid token', async () => { - const payload = await AuthManager.verifyToken('invalid.token.here') + it('should return null (session is verified by Better Auth)', async () => { + const payload = await AuthManager.verifyToken('any-token') expect(payload).toBeNull() }) - - it('should return null for malformed token', async () => { - const payload = await AuthManager.verifyToken('not-a-jwt-token') - expect(payload).toBeNull() - }) - - it('should include expiration time in payload', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - const payload = await AuthManager.verifyToken(token) - - expect(payload?.exp).toBeTruthy() - expect(payload?.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)) - }) - - it('should include issued at time in payload', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - const payload = await AuthManager.verifyToken(token) - - expect(payload?.iat).toBeTruthy() - expect(payload?.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) - }) }) describe('hashPassword', () => { @@ -70,7 +26,7 @@ describe('AuthManager', () => { expect(hash).toBeTruthy() expect(typeof hash).toBe('string') expect(hash).not.toBe(password) - expect(hash.length).toBe(64) // SHA-256 produces 64 hex characters + expect(hash.length).toBe(64) }) it('should generate same hash for same password', async () => { @@ -114,35 +70,38 @@ describe('AuthManager', () => { expect(isValid).toBe(false) }) }) + + describe('setAuthCookie', () => { + it('should be defined and callable', () => { + expect(AuthManager.setAuthCookie).toBeDefined() + expect(typeof AuthManager.setAuthCookie).toBe('function') + }) + }) }) describe('requireAuth middleware', () => { - let mockContext: any + let mockContext: Context let mockNext: Next beforeEach(() => { mockNext = vi.fn() mockContext = { - req: { - header: vi.fn(), - raw: { - headers: new Headers() - } - }, + get: vi.fn(), set: vi.fn(), json: vi.fn().mockReturnValue({ error: 'Authentication required' }), redirect: vi.fn().mockReturnValue({ redirect: true }), - env: {}, - } + req: { header: vi.fn() }, + env: {} + } as unknown as Context }) - it('should reject request without token', async () => { - mockContext.req.header.mockReturnValue(undefined) + it('should reject request when user not set (no session)', async () => { + ;(mockContext.get as ReturnType).mockReturnValue(undefined) + ;(mockContext.req.header as ReturnType).mockReturnValue(undefined) const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) - // When no token is found, the middleware returns authentication required expect(mockContext.json).toHaveBeenCalledWith( { error: 'Authentication required' }, 401 @@ -150,91 +109,52 @@ describe('requireAuth middleware', () => { expect(mockNext).not.toHaveBeenCalled() }) - it('should redirect browser requests without token', async () => { - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Accept') return 'text/html' - return undefined - }) + it('should redirect browser requests when user not set', async () => { + ;(mockContext.get as ReturnType).mockReturnValue(undefined) + ;(mockContext.req.header as ReturnType).mockImplementation((name: string) => + name === 'Accept' ? 'text/html' : undefined + ) const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.redirect).toHaveBeenCalled() expect(mockNext).not.toHaveBeenCalled() }) - it('should reject request with invalid token', async () => { - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Authorization') return 'Bearer invalid-token' - return undefined - }) - - const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) - - expect(mockContext.json).toHaveBeenCalledWith( - { error: 'Invalid or expired token' }, - 401 + it('should accept request when user is set by session middleware', async () => { + const user = { userId: 'user-123', email: 'test@example.com', role: 'admin', exp: 0, iat: 0 } + ;(mockContext.get as ReturnType).mockImplementation((key: string) => + key === 'user' ? user : undefined ) - expect(mockNext).not.toHaveBeenCalled() - }) - - it('should accept request with valid token', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Authorization') return `Bearer ${token}` - return undefined - }) const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) - expect(mockContext.set).toHaveBeenCalledWith('user', expect.objectContaining({ - userId: 'user-123', - email: 'test@example.com', - role: 'admin' - })) expect(mockNext).toHaveBeenCalled() }) - - it('should extract token from cookie if not in header', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - - // Mock getCookie by setting up the header function - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Authorization') return undefined - if (name === 'cookie') return `auth_token=${token}` - return undefined - }) - - // Note: This test may need adjustment based on actual cookie handling in Hono - // The middleware uses getCookie which may work differently than header access - }) }) describe('requireRole middleware', () => { - let mockContext: any + let mockContext: Context let mockNext: Next beforeEach(() => { mockNext = vi.fn() mockContext = { get: vi.fn(), - req: { - header: vi.fn(), - }, + req: { header: vi.fn() }, json: vi.fn().mockReturnValue({ error: 'Insufficient permissions' }), - redirect: vi.fn().mockReturnValue({ redirect: true }), - } + redirect: vi.fn().mockReturnValue({ redirect: true }) + } as unknown as Context }) it('should reject request without user context', async () => { - mockContext.get.mockReturnValue(undefined) - mockContext.req.header.mockReturnValue(undefined) + ;(mockContext.get as ReturnType).mockReturnValue(undefined) + ;(mockContext.req.header as ReturnType).mockReturnValue(undefined) const middleware = requireRole('admin') - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.json).toHaveBeenCalledWith( { error: 'Authentication required' }, @@ -244,15 +164,15 @@ describe('requireRole middleware', () => { }) it('should reject user with wrong role', async () => { - mockContext.get.mockReturnValue({ - userId: 'user-123', - email: 'test@example.com', - role: 'user' - }) - mockContext.req.header.mockReturnValue(undefined) + ;(mockContext.get as ReturnType).mockImplementation((key: string) => + key === 'user' + ? { userId: 'user-123', email: 'test@example.com', role: 'user' } + : undefined + ) + ;(mockContext.req.header as ReturnType).mockReturnValue(undefined) const middleware = requireRole('admin') - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.json).toHaveBeenCalledWith( { error: 'Insufficient permissions' }, @@ -262,368 +182,145 @@ describe('requireRole middleware', () => { }) it('should accept user with correct role', async () => { - mockContext.get.mockReturnValue({ - userId: 'user-123', - email: 'test@example.com', - role: 'admin' - }) + ;(mockContext.get as ReturnType).mockImplementation((key: string) => + key === 'user' + ? { userId: 'user-123', email: 'test@example.com', role: 'admin' } + : undefined + ) const middleware = requireRole('admin') - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockNext).toHaveBeenCalled() }) it('should accept user with any of multiple allowed roles', async () => { - mockContext.get.mockReturnValue({ - userId: 'user-123', - email: 'test@example.com', - role: 'editor' - }) + ;(mockContext.get as ReturnType).mockImplementation((key: string) => + key === 'user' + ? { userId: 'user-123', email: 'test@example.com', role: 'editor' } + : undefined + ) const middleware = requireRole(['admin', 'editor']) - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockNext).toHaveBeenCalled() }) it('should redirect browser requests with insufficient permissions', async () => { - mockContext.get.mockReturnValue({ - userId: 'user-123', - email: 'test@example.com', - role: 'user' - }) - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Accept') return 'text/html' - return undefined - }) + ;(mockContext.get as ReturnType).mockImplementation((key: string) => + key === 'user' + ? { userId: 'user-123', email: 'test@example.com', role: 'user' } + : undefined + ) + ;(mockContext.req.header as ReturnType).mockImplementation((name: string) => + name === 'Accept' ? 'text/html' : undefined + ) const middleware = requireRole('admin') - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.redirect).toHaveBeenCalled() expect(mockNext).not.toHaveBeenCalled() }) -}) - -describe('optionalAuth middleware', () => { - let mockContext: any - let mockNext: Next - - beforeEach(() => { - mockNext = vi.fn() - mockContext = { - req: { - header: vi.fn(), - raw: { - headers: new Headers() - } - }, - set: vi.fn(), - } - }) - - it('should continue without user when no token provided', async () => { - mockContext.req.header.mockReturnValue(undefined) - - const middleware = optionalAuth() - await middleware(mockContext as Context, mockNext) - - expect(mockNext).toHaveBeenCalled() - expect(mockContext.set).not.toHaveBeenCalled() - }) - - it('should set user when valid token provided', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'user') - - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Authorization') return `Bearer ${token}` - return undefined - }) - - const middleware = optionalAuth() - await middleware(mockContext as Context, mockNext) - - expect(mockContext.set).toHaveBeenCalledWith('user', expect.objectContaining({ - userId: 'user-123', - email: 'test@example.com', - role: 'user' - })) - expect(mockNext).toHaveBeenCalled() - }) - - it('should continue without user when invalid token provided', async () => { - mockContext.req.header.mockImplementation((name: string) => { - if (name === 'Authorization') return 'Bearer invalid-token' - return undefined - }) - - const middleware = optionalAuth() - await middleware(mockContext as Context, mockNext) - - expect(mockNext).toHaveBeenCalled() - // User should not be set for invalid tokens - expect(mockContext.set).not.toHaveBeenCalled() - }) - - it('should handle errors gracefully and continue', async () => { - // Mock the header function to throw an error - mockContext.req.header.mockImplementation(() => { - throw new Error('Test error') - }) - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const middleware = optionalAuth() - await middleware(mockContext as Context, mockNext) - - // Should continue despite the error - expect(mockNext).toHaveBeenCalled() - expect(consoleSpy).toHaveBeenCalledWith('Optional auth error:', expect.any(Error)) - - consoleSpy.mockRestore() - }) -}) - -describe('AuthManager.verifyToken - Expiration', () => { - it('should return null for expired token', async () => { - // Create a mock expired token by generating one and manually testing expiration logic - // Since we can't easily create an expired JWT, we'll test the verification path - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - - // The token should be valid now - const payload = await AuthManager.verifyToken(token) - expect(payload).not.toBeNull() - expect(payload?.userId).toBe('user-123') - }) -}) -describe('AuthManager.setAuthCookie', () => { - it('should set auth cookie with default options', () => { - const mockSetCookie = vi.fn() - const mockContext = { - env: {} - } + it('should redirect browser when no user context and HTML accept', async () => { + ;(mockContext.get as ReturnType).mockReturnValue(undefined) + ;(mockContext.req.header as ReturnType).mockImplementation((name: string) => + name === 'Accept' ? 'text/html' : undefined + ) - // We can't easily test this without mocking hono/cookie - // But we verify the method exists and is callable - expect(AuthManager.setAuthCookie).toBeDefined() - expect(typeof AuthManager.setAuthCookie).toBe('function') - }) + const middleware = requireRole('admin') + await middleware(mockContext, mockNext) - it('should accept custom cookie options', () => { - // Verify the method signature accepts options - expect(AuthManager.setAuthCookie.length).toBeGreaterThanOrEqual(2) + expect(mockContext.redirect).toHaveBeenCalledWith( + expect.stringContaining('/auth/login?error=') + ) + expect(mockNext).not.toHaveBeenCalled() }) }) -describe('requireAuth middleware - KV Cache', () => { - let mockContext: any +describe('optionalAuth middleware', () => { + let mockContext: Context let mockNext: Next beforeEach(() => { mockNext = vi.fn() + mockContext = { + req: { header: vi.fn(), raw: { headers: new Headers() } }, + set: vi.fn() + } as unknown as Context }) - it('should use cached token verification from KV when available', async () => { - const cachedPayload = { - userId: 'cached-user', - email: 'cached@example.com', - role: 'admin', - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000) - } - - const mockKv = { - get: vi.fn().mockResolvedValue(cachedPayload), - put: vi.fn() - } - - mockContext = { - req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Authorization') return 'Bearer some-valid-token-prefix' - return undefined - }), - raw: { headers: new Headers() } - }, - set: vi.fn(), - json: vi.fn(), - redirect: vi.fn(), - env: { KV: mockKv } - } + it('should always call next (user is set by global session middleware)', async () => { + ;(mockContext.req.header as ReturnType).mockReturnValue(undefined) - const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + const middleware = optionalAuth() + await middleware(mockContext, mockNext) - // Should have checked the cache - expect(mockKv.get).toHaveBeenCalled() - // Should have set the cached user - expect(mockContext.set).toHaveBeenCalledWith('user', cachedPayload) expect(mockNext).toHaveBeenCalled() }) - it('should cache verified token in KV', async () => { - const token = await AuthManager.generateToken('user-123', 'test@example.com', 'admin') - - const mockKv = { - get: vi.fn().mockResolvedValue(null), // Cache miss - put: vi.fn().mockResolvedValue(undefined) - } - - mockContext = { - req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Authorization') return `Bearer ${token}` - return undefined - }), - raw: { headers: new Headers() } - }, - set: vi.fn(), - json: vi.fn(), - redirect: vi.fn(), - env: { KV: mockKv } - } - - const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + it('should continue when no user in context', async () => { + const middleware = optionalAuth() + await middleware(mockContext, mockNext) - // Should have tried to get from cache - expect(mockKv.get).toHaveBeenCalled() - // Should have stored in cache after verification - expect(mockKv.put).toHaveBeenCalled() expect(mockNext).toHaveBeenCalled() }) }) describe('requireAuth middleware - Error Handling', () => { - let mockContext: any + let mockContext: Context let mockNext: Next beforeEach(() => { mockNext = vi.fn() }) - it('should redirect browser on auth error', async () => { + it('should redirect browser on missing user when Accept is text/html', async () => { mockContext = { + get: vi.fn().mockReturnValue(undefined), req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Authorization') { - throw new Error('Simulated error') - } - if (name === 'Accept') return 'text/html' - return undefined - }), - raw: { headers: new Headers() } + header: vi.fn().mockImplementation((name: string) => + name === 'Accept' ? 'text/html' : undefined + ) }, set: vi.fn(), json: vi.fn(), redirect: vi.fn().mockReturnValue({ redirect: true }), env: {} - } - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + } as unknown as Context const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.redirect).toHaveBeenCalledWith( expect.stringContaining('/auth/login?error=') ) expect(mockNext).not.toHaveBeenCalled() - - consoleSpy.mockRestore() }) - it('should return JSON error on API auth error', async () => { + it('should return JSON error when no user and Accept is application/json', async () => { mockContext = { + get: vi.fn().mockReturnValue(undefined), req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Authorization') { - throw new Error('Simulated error') - } - if (name === 'Accept') return 'application/json' - return undefined - }), - raw: { headers: new Headers() } + header: vi.fn().mockImplementation((name: string) => + name === 'Accept' ? 'application/json' : undefined + ) }, set: vi.fn(), - json: vi.fn().mockReturnValue({ error: 'Authentication failed' }), + json: vi.fn().mockReturnValue({ error: 'Authentication required' }), redirect: vi.fn(), env: {} - } - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + } as unknown as Context const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) + await middleware(mockContext, mockNext) expect(mockContext.json).toHaveBeenCalledWith( - { error: 'Authentication failed' }, + { error: 'Authentication required' }, 401 ) expect(mockNext).not.toHaveBeenCalled() - - consoleSpy.mockRestore() - }) - - it('should redirect browser when invalid token and HTML accept', async () => { - mockContext = { - req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Authorization') return 'Bearer invalid-token' - if (name === 'Accept') return 'text/html' - return undefined - }), - raw: { headers: new Headers() } - }, - set: vi.fn(), - json: vi.fn(), - redirect: vi.fn().mockReturnValue({ redirect: true }), - env: {} - } - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const middleware = requireAuth() - await middleware(mockContext as Context, mockNext) - - expect(mockContext.redirect).toHaveBeenCalledWith( - expect.stringContaining('/auth/login?error=') - ) - expect(mockNext).not.toHaveBeenCalled() - - consoleSpy.mockRestore() - }) -}) - -describe('requireRole middleware - Browser Redirects', () => { - let mockContext: any - let mockNext: Next - - beforeEach(() => { - mockNext = vi.fn() - }) - - it('should redirect browser when no user context and HTML accept', async () => { - mockContext = { - get: vi.fn().mockReturnValue(undefined), - req: { - header: vi.fn().mockImplementation((name: string) => { - if (name === 'Accept') return 'text/html' - return undefined - }) - }, - json: vi.fn(), - redirect: vi.fn().mockReturnValue({ redirect: true }) - } - - const middleware = requireRole('admin') - await middleware(mockContext as Context, mockNext) - - expect(mockContext.redirect).toHaveBeenCalledWith( - expect.stringContaining('/auth/login?error=') - ) - expect(mockNext).not.toHaveBeenCalled() }) }) diff --git a/packages/core/src/__tests__/plugins/otp-login.test.ts b/packages/core/src/__tests__/plugins/otp-login.test.ts deleted file mode 100644 index d65daa5ee..000000000 --- a/packages/core/src/__tests__/plugins/otp-login.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { OTPService } from '../../plugins/core-plugins/otp-login-plugin/otp-service' - -// Mock D1 Database -const createMockDB = () => { - const mockData = { - otpCodes: [] as any[], - users: [ - { id: 'user-1', email: 'test@example.com', role: 'admin', is_active: 1 } - ] - } - - return { - prepare: vi.fn((sql: string) => ({ - bind: vi.fn((...args: any[]) => ({ - run: vi.fn().mockResolvedValue({ meta: { changes: 1 } }), - first: vi.fn().mockResolvedValue( - sql.includes('otp_codes') && sql.includes('SELECT') - ? mockData.otpCodes[0] - : sql.includes('users') - ? mockData.users[0] - : null - ), - all: vi.fn().mockResolvedValue({ results: mockData.otpCodes }) - })) - })) - } as any -} - -describe('OTP Login Plugin', () => { - describe('OTPService', () => { - let mockDB: any - let otpService: OTPService - - beforeEach(() => { - mockDB = createMockDB() - otpService = new OTPService(mockDB) - }) - - describe('generateCode', () => { - it('should generate code of specified length', () => { - const code = otpService.generateCode(6) - expect(code).toHaveLength(6) - expect(code).toMatch(/^[0-9]+$/) - }) - - it('should generate different codes each time', () => { - const code1 = otpService.generateCode(6) - const code2 = otpService.generateCode(6) - expect(code1).not.toBe(code2) - }) - - it('should handle different lengths', () => { - expect(otpService.generateCode(4)).toHaveLength(4) - expect(otpService.generateCode(8)).toHaveLength(8) - }) - }) - - describe('createOTPCode', () => { - it('should create OTP code with correct structure', async () => { - const settings = { - codeLength: 6, - codeExpiryMinutes: 10, - maxAttempts: 3, - rateLimitPerHour: 5, - allowNewUserRegistration: false, - appName: 'Test App' - } - - const otpCode = await otpService.createOTPCode( - 'test@example.com', - settings, - '127.0.0.1', - 'Mozilla/5.0' - ) - - expect(otpCode).toMatchObject({ - user_email: 'test@example.com', - used: 0, - attempts: 0, - ip_address: '127.0.0.1', - user_agent: 'Mozilla/5.0' - }) - expect(otpCode.code).toHaveLength(6) - expect(otpCode.expires_at).toBeGreaterThan(Date.now()) - }) - - it('should normalize email to lowercase', async () => { - const settings = { - codeLength: 6, - codeExpiryMinutes: 10, - maxAttempts: 3, - rateLimitPerHour: 5, - allowNewUserRegistration: false, - appName: 'Test App' - } - - const otpCode = await otpService.createOTPCode( - 'TEST@EXAMPLE.COM', - settings - ) - - expect(otpCode.user_email).toBe('test@example.com') - }) - }) - }) - - describe('API Endpoints', () => { - it('should validate email format on request', () => { - const invalidEmails = [ - 'notanemail', - '@example.com', - 'test@', - 'test@.com' - ] - - for (const email of invalidEmails) { - const atIndex = email.indexOf('@') - const lastDotIndex = email.lastIndexOf('.') - const isValid = atIndex > 0 && // has characters before @ - lastDotIndex > atIndex + 1 && // has characters between @ and last dot - lastDotIndex < email.length - 1 // has characters after last dot - expect(isValid).toBe(false) - } - }) - - it('should accept valid email format', () => { - const validEmails = [ - 'test@example.com', - 'user+tag@domain.co.uk', - 'admin@test.org' - ] - - for (const email of validEmails) { - expect(email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) - } - }) - }) - - describe('Security', () => { - it('should generate cryptographically secure codes', () => { - const codes = new Set() - const iterations = 100 - - for (let i = 0; i < iterations; i++) { - const service = new OTPService(createMockDB()) - codes.add(service.generateCode(6)) - } - - // Should have high uniqueness (at least 95% unique) - expect(codes.size).toBeGreaterThan(iterations * 0.95) - }) - - it('should handle rate limiting check', async () => { - const otpService = new OTPService(createMockDB()) - const settings = { - codeLength: 6, - codeExpiryMinutes: 10, - maxAttempts: 3, - rateLimitPerHour: 5, - allowNewUserRegistration: false, - appName: 'Test App' - } - - const canRequest = await otpService.checkRateLimit('test@example.com', settings) - expect(typeof canRequest).toBe('boolean') - }) - }) -}) diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index 3865b457c..89cd8467a 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -32,11 +32,10 @@ import { metricsMiddleware } from './middleware/metrics' import { createDatabaseToolsAdminRoutes } from './plugins/core-plugins/database-tools-plugin/admin-routes' import { createSeedDataAdminRoutes } from './plugins/core-plugins/seed-data-plugin/admin-routes' import { emailPlugin } from './plugins/core-plugins/email-plugin' -import { otpLoginPlugin } from './plugins/core-plugins/otp-login-plugin' import { aiSearchPlugin } from './plugins/core-plugins/ai-search-plugin' -import { createMagicLinkAuthPlugin } from './plugins/available/magic-link-auth' import cachePlugin from './plugins/cache' import { faviconSvg } from './assets/favicon' +import { createAuth } from './auth/config' // ============================================================================ // Type Definitions @@ -47,6 +46,8 @@ export interface Bindings { CACHE_KV: KVNamespace MEDIA_BUCKET: R2Bucket ASSETS: Fetcher + BETTER_AUTH_SECRET?: string + BETTER_AUTH_URL?: string EMAIL_QUEUE?: Queue SENDGRID_API_KEY?: string DEFAULT_FROM_EMAIL?: string @@ -65,6 +66,7 @@ export interface Variables { exp: number iat: number } + session?: { id: string; userId: string; token: string; expiresAt: number; createdAt: number; updatedAt: number } requestId?: string startTime?: number appVersion?: string @@ -90,6 +92,21 @@ export interface SonicJSConfig { handler: Hono }> + /** + * Better Auth configuration override. + * Use extendBetterAuth to add social providers, magic link, 2FA, or other login methods. + * @example + * auth: { + * extendBetterAuth: (defaults) => ({ + * ...defaults, + * socialProviders: { google: { clientId: '...', clientSecret: '...' } }, + * }), + * } + */ + auth?: { + extendBetterAuth?: import('./auth/config').ExtendBetterAuth + } + // Custom middleware middleware?: { beforeAuth?: Array<(c: Context, next: () => Promise) => Promise> @@ -176,6 +193,45 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { } } + // Better Auth session middleware: set c.set('user') from Better Auth session for compatibility + type AppEnv = { Bindings: Bindings; Variables: Variables } + app.use('*', async (c: Context, next) => { + try { + const auth = createAuth(c.env, config.auth?.extendBetterAuth) + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + if (session) { + const user = session.user as { id: string; email: string; role?: string } + const exp = typeof session.session.expiresAt === 'number' ? session.session.expiresAt : (session.session.expiresAt as Date).getTime() + const iat = typeof session.session.createdAt === 'number' ? session.session.createdAt : (session.session.createdAt as Date).getTime() + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('user', { + userId: user.id, + email: user.email, + role: user.role ?? 'viewer', + exp, + iat + }) + const expiresAt = typeof session.session.expiresAt === 'number' ? session.session.expiresAt : (session.session.expiresAt as Date).getTime() + const createdAt = typeof session.session.createdAt === 'number' ? session.session.createdAt : (session.session.createdAt as Date).getTime() + const updatedAt = typeof session.session.updatedAt === 'number' ? session.session.updatedAt : (session.session.updatedAt as Date).getTime() + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('session', { + id: session.session.id, + userId: session.session.userId, + token: session.session.token, + expiresAt, + createdAt, + updatedAt + }) + } else { + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('user', undefined) + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('session', undefined) + } + } catch { + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('user', undefined) + ;(c.set as (key: keyof Variables, value: Variables[keyof Variables]) => void)('session', undefined) + } + await next() + }) + // Core routes // Routes are being imported incrementally from routes/* // Each route is tested and migrated one-by-one @@ -206,18 +262,15 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Fixes GitHub Issue #461: Cache routes were not registered app.route('/admin/cache', cachePlugin.getRoutes()) - // Plugin routes - OTP Login (MUST be registered BEFORE admin/plugins to avoid route conflict) - // Register OTP Login routes first so they take precedence over the generic /:id handler - if (otpLoginPlugin.routes && otpLoginPlugin.routes.length > 0) { - for (const route of otpLoginPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - app.route('/admin/plugins', adminPluginRoutes) app.route('/admin/logs', adminLogsRoutes) app.route('/admin', adminUsersRoutes) app.route('/auth', authRoutes) + // Better Auth handler for /auth/sign-in/*, /auth/sign-up/*, /auth/sign-out, /auth/get-session, etc. + app.on(['GET', 'POST'], '/auth/*', async (c) => { + const auth = createAuth(c.env, config.auth?.extendBetterAuth) + return auth.handler(c.req.raw) + }) // Test cleanup routes (only for development/test environments) app.route('/', testCleanupRoutes) @@ -229,14 +282,6 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { } } - // Plugin routes - Magic Link Auth (passwordless authentication via email links) - const magicLinkPlugin = createMagicLinkAuthPlugin() - if (magicLinkPlugin.routes && magicLinkPlugin.routes.length > 0) { - for (const route of magicLinkPlugin.routes) { - app.route(route.path, route.handler as any) - } - } - // Serve favicon app.get('/favicon.svg', (c) => { return new Response(faviconSvg, { diff --git a/packages/core/src/auth/config.ts b/packages/core/src/auth/config.ts new file mode 100644 index 000000000..f6b37c83c --- /dev/null +++ b/packages/core/src/auth/config.ts @@ -0,0 +1,152 @@ +/** + * Better Auth configuration for SonicJS + * Factory creates auth instance with runtime env (DB, secrets) for Cloudflare Workers. + * Supports config.auth.extendBetterAuth in SonicJSConfig to add social providers, magic link, 2FA, etc. + */ + +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { drizzle } from 'drizzle-orm/d1' +import { APIError } from 'better-auth/api' +import { users, session, account, verification } from '../db/schema' +import { isRegistrationEnabled, isFirstUserRegistration } from '../services/auth-validation' +import type { Bindings } from '../app' + +/** + * Build the default Better Auth options used by SonicJS. + * Exported so users can extend via config.auth.extendBetterAuth in createSonicJSApp(). + */ +export function getDefaultAuthOptions(env: Bindings) { + const db = drizzle(env.DB) + + return { + database: drizzleAdapter(db, { + provider: 'sqlite', + schema: { + user: users, + session, + account, + verification + } + }), + basePath: '/auth', + baseURL: env.BETTER_AUTH_URL, + secret: env.BETTER_AUTH_SECRET, + appName: 'SonicJS', + emailAndPassword: { + enabled: true, + autoSignIn: true + }, + user: { + modelName: 'users', + fields: { + image: 'avatar', + emailVerified: 'email_verified', + createdAt: 'created_at', + updatedAt: 'updated_at', + firstName: 'first_name', + lastName: 'last_name' + }, + additionalFields: { + role: { + type: 'string', + required: false, + defaultValue: 'viewer', + input: false + }, + username: { + type: 'string', + required: false, + defaultValue: '', + input: true + }, + firstName: { + type: 'string', + required: false, + defaultValue: '', + input: true + }, + lastName: { + type: 'string', + required: false, + defaultValue: '', + input: true + } + } + }, + session: { + modelName: 'session', + fields: { + userId: 'user_id', + expiresAt: 'expires_at', + ipAddress: 'ip_address', + userAgent: 'user_agent', + createdAt: 'created_at', + updatedAt: 'updated_at' + }, + expiresIn: 60 * 60 * 24, // 24 hours + updateAge: 60 * 60 * 24, + storeSessionInDatabase: true + }, + databaseHooks: { + user: { + create: { + before: async (userData: Record, _ctx: unknown) => { + const d1 = env.DB + const isFirst = await isFirstUserRegistration(d1) + if (!isFirst) { + const enabled = await isRegistrationEnabled(d1) + if (!enabled) { + throw new APIError('BAD_REQUEST', { + message: 'Registration is currently disabled.' + }) + } + } + const name = ((userData as { name?: string }).name ?? 'User') as string + const parts = name.trim().split(/\s+/) + const firstName = parts[0] ?? 'User' + const lastName = parts.slice(1).join(' ') || firstName + const email = (userData as { email?: string }).email ?? '' + const username = email ? email.split('@')[0]! : `user${Date.now()}` + return { + data: { + ...userData, + name, + firstName, + lastName, + username, + role: 'viewer' + } as typeof userData + } + }, + after: async (user: { id: string }, _ctx: unknown) => { + const d1 = env.DB + const result = (await d1 + .prepare('SELECT COUNT(*) as count FROM users') + .first()) as { count: number } | null + const count = result?.count ?? 0 + const role = count === 1 ? 'admin' : 'viewer' + await d1.prepare('UPDATE users SET role = ? WHERE id = ?').bind(role, user.id).run() + } + } + } + } + } +} + +export type BetterAuthDefaultOptions = ReturnType + +export type ExtendBetterAuth = (defaultOptions: BetterAuthDefaultOptions) => BetterAuthDefaultOptions + +/** + * Create Better Auth instance with D1 database and SonicJS-specific hooks. + * Pass optional extendBetterAuth (from config.auth.extendBetterAuth) to add social providers, + * magic link, 2FA, or other login methods. + */ +export function createAuth(env: Bindings, extendBetterAuth?: ExtendBetterAuth) { + const defaultOptions = getDefaultAuthOptions(env) + const options = extendBetterAuth ? extendBetterAuth(defaultOptions) : defaultOptions + return betterAuth(options as Parameters[0]) +} + +export type SonicJSAuth = ReturnType diff --git a/packages/core/src/db/migrations-bundle.ts b/packages/core/src/db/migrations-bundle.ts index c37a00010..26fb022b5 100644 --- a/packages/core/src/db/migrations-bundle.ts +++ b/packages/core/src/db/migrations-bundle.ts @@ -1,7 +1,7 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT * Generated by: scripts/generate-migrations.ts - * Generated at: 2026-01-30T06:55:27.442Z + * Generated at: 2026-01-31T20:33:49.847Z * * This file contains all migration SQL bundled for use in Cloudflare Workers * where filesystem access is not available at runtime. @@ -225,6 +225,20 @@ export const bundledMigrations: BundledMigration[] = [ filename: '031_ai_search_plugin.sql', description: 'Migration 031: Ai Search Plugin', sql: "-- AI Search plugin settings\nCREATE TABLE IF NOT EXISTS ai_search_settings (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n enabled BOOLEAN DEFAULT 0,\n ai_mode_enabled BOOLEAN DEFAULT 1,\n selected_collections TEXT, -- JSON array of collection IDs to index\n dismissed_collections TEXT, -- JSON array of collection IDs user chose not to index\n autocomplete_enabled BOOLEAN DEFAULT 1,\n cache_duration INTEGER DEFAULT 1, -- hours\n results_limit INTEGER DEFAULT 20,\n index_media BOOLEAN DEFAULT 0,\n index_status TEXT, -- JSON object with status per collection\n last_indexed_at INTEGER,\n created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),\n updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)\n);\n\n-- Search history/analytics\nCREATE TABLE IF NOT EXISTS ai_search_history (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n query TEXT NOT NULL,\n mode TEXT, -- 'ai' or 'keyword'\n results_count INTEGER,\n user_id INTEGER,\n created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000)\n);\n\n-- Index metadata tracking (per collection)\nCREATE TABLE IF NOT EXISTS ai_search_index_meta (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n collection_id INTEGER NOT NULL,\n collection_name TEXT NOT NULL, -- Cache collection name for display\n total_items INTEGER DEFAULT 0,\n indexed_items INTEGER DEFAULT 0,\n last_sync_at INTEGER,\n status TEXT DEFAULT 'pending', -- 'pending', 'indexing', 'completed', 'error'\n error_message TEXT,\n UNIQUE(collection_id)\n);\n\n-- Indexes for performance\nCREATE INDEX IF NOT EXISTS idx_ai_search_history_created_at ON ai_search_history(created_at);\nCREATE INDEX IF NOT EXISTS idx_ai_search_history_mode ON ai_search_history(mode);\nCREATE INDEX IF NOT EXISTS idx_ai_search_index_meta_collection_id ON ai_search_index_meta(collection_id);\nCREATE INDEX IF NOT EXISTS idx_ai_search_index_meta_status ON ai_search_index_meta(status);\n" + }, + { + id: '032', + name: 'Better Auth', + filename: '032_better_auth.sql', + description: 'Migration 032: Better Auth', + sql: "-- Better Auth: Add name column to users and create session, account, verification tables\n-- Required by better-auth (https://better-auth.com/docs/concepts/database)\n\n-- Add name column for Better Auth (required for registration)\n-- Existing rows: set name from first_name + ' ' + last_name in application or leave null\nALTER TABLE users ADD COLUMN name TEXT;\n\n-- Better Auth session table (cookie-based sessions)\nCREATE TABLE IF NOT EXISTS session (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n token TEXT NOT NULL UNIQUE,\n expires_at INTEGER NOT NULL,\n ip_address TEXT,\n user_agent TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id);\nCREATE INDEX IF NOT EXISTS idx_session_token ON session(token);\nCREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at);\n\n-- Better Auth account table (credentials + OAuth providers)\nCREATE TABLE IF NOT EXISTS account (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n account_id TEXT NOT NULL,\n provider_id TEXT NOT NULL,\n access_token TEXT,\n refresh_token TEXT,\n access_token_expires_at INTEGER,\n refresh_token_expires_at INTEGER,\n scope TEXT,\n id_token TEXT,\n password TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_account_user_id ON account(user_id);\nCREATE INDEX IF NOT EXISTS idx_account_provider ON account(provider_id, account_id);\n\n-- Better Auth verification table (email verification, password reset, etc.)\nCREATE TABLE IF NOT EXISTS verification (\n id TEXT PRIMARY KEY,\n identifier TEXT NOT NULL,\n value TEXT NOT NULL,\n expires_at INTEGER NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_verification_identifier ON verification(identifier);\nCREATE INDEX IF NOT EXISTS idx_verification_expires_at ON verification(expires_at);\n" + }, + { + id: '033', + name: 'Drop Otp And Magic Link Tables', + filename: '033_drop_otp_and_magic_link_tables.sql', + description: 'Migration 033: Drop Otp And Magic Link Tables', + sql: "-- Drop OTP and Magic Link plugin tables\n-- Migration: 033_drop_otp_and_magic_link_tables\n-- Description: Remove magic_links and otp_codes tables (plugins removed in favor of Better Auth extendBetterAuth)\n\n-- Drop magic link auth table and indexes (indexes are dropped with the table in SQLite)\nDROP TABLE IF EXISTS magic_links;\n\n-- Drop OTP login table and indexes\nDROP TABLE IF EXISTS otp_codes;\n\n-- Remove plugin registry entries so they no longer appear in admin\nDELETE FROM plugins WHERE id IN ('magic-link-auth', 'otp-login');\n" } ] diff --git a/packages/core/src/db/schema.ts b/packages/core/src/db/schema.ts index 827943347..56a4d6687 100644 --- a/packages/core/src/db/schema.ts +++ b/packages/core/src/db/schema.ts @@ -6,6 +6,7 @@ export const users = sqliteTable('users', { id: text('id').primaryKey(), email: text('email').notNull().unique(), username: text('username').notNull().unique(), + name: text('name'), // Better Auth display name (required by Better Auth for registration) firstName: text('first_name').notNull(), lastName: text('last_name').notNull(), passwordHash: text('password_hash'), // Hashed password, nullable for OAuth users @@ -17,6 +18,49 @@ export const users = sqliteTable('users', { updatedAt: integer('updated_at').notNull(), }); +// Better Auth session table (cookie-based sessions) +export const session = sqliteTable('session', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: integer('expires_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}); + +// Better Auth account table (credentials + OAuth providers) +export const account = sqliteTable('account', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + accessTokenExpiresAt: integer('access_token_expires_at'), + refreshTokenExpiresAt: integer('refresh_token_expires_at'), + scope: text('scope'), + idToken: text('id_token'), + password: text('password'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}); + +// Better Auth verification table (email verification, password reset, etc.) +export const verification = sqliteTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at').notNull(), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}); + // Content collections - dynamic schema definitions export const collections = sqliteTable('collections', { id: text('id').primaryKey(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d00832cf3..f305b01af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,9 @@ export { createSonicJSApp, setupCoreMiddleware, setupCoreRoutes } from './app' export type { SonicJSConfig, SonicJSApp, Bindings, Variables } from './app' +export { createAuth, getDefaultAuthOptions } from './auth/config' +export type { BetterAuthDefaultOptions, ExtendBetterAuth, SonicJSAuth } from './auth/config' + // ============================================================================ // Placeholders - To be populated in Phase 2 // ============================================================================ diff --git a/packages/core/src/middleware/auth.ts b/packages/core/src/middleware/auth.ts index 29c501ed4..a33910945 100644 --- a/packages/core/src/middleware/auth.ts +++ b/packages/core/src/middleware/auth.ts @@ -1,8 +1,8 @@ -import { sign, verify } from 'hono/jwt' -import { Context, Next } from 'hono' -import { getCookie, setCookie } from 'hono/cookie' +import type { Context, Next } from 'hono' +import { setCookie } from 'hono/cookie' -type JWTPayload = { +/** User shape set by Better Auth session middleware (compatibility with c.get('user')) */ +export type AuthUserPayload = { userId: string email: string role: string @@ -10,40 +10,27 @@ type JWTPayload = { iat: number } -// JWT secret - in production this should come from environment variables -const JWT_SECRET = 'your-super-secret-jwt-key-change-in-production' - +/** + * AuthManager: legacy helpers for seed-admin and plugins. + * Main auth is handled by Better Auth; session is set by global middleware. + */ export class AuthManager { - static async generateToken(userId: string, email: string, role: string): Promise { - const payload: JWTPayload = { - userId, - email, - role, - exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24), // 24 hours - iat: Math.floor(Date.now() / 1000) - } - - return await sign(payload, JWT_SECRET, 'HS256') + /** + * @deprecated Use Better Auth for sign-in. Kept for seed-admin and plugin compatibility. + */ + static async generateToken(_userId: string, _email: string, _role: string): Promise { + throw new Error('Use Better Auth for authentication. JWT generation is deprecated.') } - static async verifyToken(token: string): Promise { - try { - const payload = await verify(token, JWT_SECRET, 'HS256') as JWTPayload - - // Check if token is expired - if (payload.exp < Math.floor(Date.now() / 1000)) { - return null - } - - return payload - } catch (error) { - console.error('Token verification failed:', error) - return null - } + /** + * @deprecated Session is verified by Better Auth. Kept for type compatibility. + */ + static async verifyToken(_token: string): Promise { + return null } + /** Password hashing for seed-admin (Better Auth uses its own hashing for normal sign-up/sign-in). */ static async hashPassword(password: string): Promise { - // In Cloudflare Workers, we'll use Web Crypto API const encoder = new TextEncoder() const data = encoder.encode(password + 'salt-change-in-production') const hashBuffer = await crypto.subtle.digest('SHA-256', data) @@ -57,146 +44,69 @@ export class AuthManager { } /** - * Set authentication cookie - useful for plugins implementing alternative auth methods - * @param c - Hono context - * @param token - JWT token to set in cookie - * @param options - Optional cookie configuration + * Set authentication cookie - useful for plugins implementing alternative auth methods. + * @deprecated Better Auth sets its own session cookie. Kept for plugin compatibility. */ - static setAuthCookie(c: Context, token: string, options?: { - maxAge?: number - secure?: boolean - httpOnly?: boolean - sameSite?: 'Strict' | 'Lax' | 'None' - }): void { + static setAuthCookie( + c: Context, + token: string, + options?: { + maxAge?: number + secure?: boolean + httpOnly?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' + } + ): void { setCookie(c, 'auth_token', token, { httpOnly: options?.httpOnly ?? true, secure: options?.secure ?? true, sameSite: options?.sameSite ?? 'Strict', - maxAge: options?.maxAge ?? (60 * 60 * 24) // 24 hours default + maxAge: options?.maxAge ?? 60 * 60 * 24 }) } } -// Middleware to require authentication +/** Require authentication. Relies on global session middleware to set c.set('user'). */ export const requireAuth = () => { return async (c: Context, next: Next) => { - try { - // Try to get token from Authorization header - let token = c.req.header('Authorization')?.replace('Bearer ', '') - - // If no header token, try cookie - if (!token) { - token = getCookie(c, 'auth_token') - } - - if (!token) { - // Check if this is a browser request (HTML accept header) - const acceptHeader = c.req.header('Accept') || '' - if (acceptHeader.includes('text/html')) { - return c.redirect('/auth/login?error=Please login to access the admin area') - } - return c.json({ error: 'Authentication required' }, 401) - } - - // Try to get cached token verification from KV - const kv = c.env?.KV - let payload: JWTPayload | null = null - - if (kv) { - const cacheKey = `auth:${token.substring(0, 20)}` // Use token prefix as key - const cached = await kv.get(cacheKey, 'json') - if (cached) { - payload = cached as JWTPayload - } - } - - // If not cached, verify token - if (!payload) { - payload = await AuthManager.verifyToken(token) - - // Cache the verified payload for 5 minutes - if (payload && kv) { - const cacheKey = `auth:${token.substring(0, 20)}` - await kv.put(cacheKey, JSON.stringify(payload), { expirationTtl: 300 }) - } - } - - if (!payload) { - // Check if this is a browser request (HTML accept header) - const acceptHeader = c.req.header('Accept') || '' - if (acceptHeader.includes('text/html')) { - return c.redirect('/auth/login?error=Your session has expired, please login again') - } - return c.json({ error: 'Invalid or expired token' }, 401) - } - - // Add user info to context - c.set('user', payload) - - return await next() - } catch (error) { - console.error('Auth middleware error:', error) - // Check if this is a browser request (HTML accept header) - const acceptHeader = c.req.header('Accept') || '' + const user = c.get('user') as AuthUserPayload | undefined + if (!user) { + const acceptHeader = c.req.header('Accept') ?? '' if (acceptHeader.includes('text/html')) { - return c.redirect('/auth/login?error=Authentication failed, please login again') + return c.redirect('/auth/login?error=Please login to access the admin area') } - return c.json({ error: 'Authentication failed' }, 401) + return c.json({ error: 'Authentication required' }, 401) } + return await next() } } -// Middleware to require specific role +/** Require specific role. Must run after requireAuth or session middleware. */ export const requireRole = (requiredRole: string | string[]) => { return async (c: Context, next: Next) => { - const user = c.get('user') as JWTPayload - + const user = c.get('user') as AuthUserPayload | undefined if (!user) { - // Check if this is a browser request (HTML accept header) - const acceptHeader = c.req.header('Accept') || '' + const acceptHeader = c.req.header('Accept') ?? '' if (acceptHeader.includes('text/html')) { return c.redirect('/auth/login?error=Please login to access the admin area') } return c.json({ error: 'Authentication required' }, 401) } - const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole] - if (!roles.includes(user.role)) { - // Check if this is a browser request (HTML accept header) - const acceptHeader = c.req.header('Accept') || '' + const acceptHeader = c.req.header('Accept') ?? '' if (acceptHeader.includes('text/html')) { return c.redirect('/auth/login?error=You do not have permission to access this area') } return c.json({ error: 'Insufficient permissions' }, 403) } - return await next() } } -// Optional auth middleware (doesn't block if no token) +/** Optional auth: user may already be set by global session middleware; no-op if not. */ export const optionalAuth = () => { return async (c: Context, next: Next) => { - try { - let token = c.req.header('Authorization')?.replace('Bearer ', '') - - if (!token) { - token = getCookie(c, 'auth_token') - } - - if (token) { - const payload = await AuthManager.verifyToken(token) - if (payload) { - c.set('user', payload) - } - } - - return await next() - } catch (error) { - // Don't block on auth errors in optional auth - console.error('Optional auth error:', error) - return await next() - } + await next() } } diff --git a/packages/core/src/plugins/available/magic-link-auth/README.md b/packages/core/src/plugins/available/magic-link-auth/README.md deleted file mode 100644 index 2db11a8d0..000000000 --- a/packages/core/src/plugins/available/magic-link-auth/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# Magic Link Authentication Plugin - -Passwordless authentication for SonicJS via email magic links. Users sign in by clicking a secure one-time link sent to their email - no password required. - -## Features - -- ✅ **Passwordless**: No passwords to remember or manage -- ✅ **Secure**: One-time use tokens with configurable expiration -- ✅ **Rate Limited**: Prevents abuse with configurable request limits -- ✅ **Email Integration**: Uses SonicJS email plugin (Resend) -- ✅ **User Tracking**: Logs IP and user agent for security -- ✅ **Auto Cleanup**: Expired links are automatically ignored - -## Status - -**Inactive by default** - Most developers prefer traditional email/password authentication. - -## Installation - -The plugin is included with SonicJS but inactive by default. - -### Prerequisites - -1. **Email Plugin**: Must be installed and configured - - Go to Admin → Plugins → Email Plugin - - Configure Resend API key - - Set from email and name - -### Activation - -1. Navigate to **Admin → Plugins** -2. Find "Magic Link Authentication" -3. Click **Activate** -4. Configure settings (optional) - -## Configuration - -| Setting | Default | Description | -|---------|---------|-------------| -| Link Expiry (minutes) | 15 | How long magic links remain valid | -| Rate Limit (per hour) | 5 | Maximum requests per email per hour | -| Allow New User Registration | false | Allow new users to register via magic link | - -## Usage - -### For End Users - -1. Go to `/auth/magic-link/request` -2. Enter email address -3. Click "Send Magic Link" -4. Check email for magic link -5. Click link to sign in - -### API Endpoints - -#### Request Magic Link - -```bash -POST /auth/magic-link/request -Content-Type: application/json - -{ - "email": "user@example.com" -} -``` - -**Response:** -```json -{ - "message": "If an account exists for this email, you will receive a magic link shortly." -} -``` - -#### Verify Magic Link - -```bash -GET /auth/magic-link/verify?token= -``` - -Redirects to dashboard on success, login page on failure. - -## Security Features - -- **One-time use**: Links can only be used once -- **Time-limited**: Links expire after configured minutes (default: 15) -- **Rate limiting**: Prevents brute force attacks (default: 5 per hour) -- **IP tracking**: Logs IP address for security auditing -- **User agent tracking**: Records browser/device information -- **Secure tokens**: Uses crypto.randomUUID() for token generation -- **Email verification**: Only sends to verified email addresses -- **No user enumeration**: Same response for existing/non-existing emails - -## Database Schema - -```sql -CREATE TABLE magic_links ( - id TEXT PRIMARY KEY, - user_email TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at INTEGER NOT NULL, - used INTEGER DEFAULT 0, - used_at INTEGER, - ip_address TEXT, - user_agent TEXT, - created_at INTEGER NOT NULL -); -``` - -## Integration Example - -### Custom Login Page - -```html - - - -``` - -## Development - -In development mode (`ENVIRONMENT=development`), the API response includes the magic link URL for testing: - -```json -{ - "message": "...", - "dev_link": "http://localhost:8787/auth/magic-link/verify?token=..." -} -``` - -**⚠️ This is automatically removed in production** - -## Troubleshooting - -### Magic link not received - -1. Check email plugin is active and configured -2. Verify Resend API key is valid -3. Check from email domain is verified in Resend -4. Check spam folder -5. Check rate limit not exceeded - -### Link expired - -- Default expiry is 15 minutes -- Increase `linkExpiryMinutes` in plugin settings -- Request a new link - -### Rate limit exceeded - -- Wait one hour or adjust `rateLimitPerHour` setting -- Default is 5 requests per hour per email - -## Dependencies - -- **email plugin**: Required for sending magic links - -## Comparison: Magic Link vs Email/Password - -| Feature | Magic Link | Email/Password | -|---------|-----------|----------------| -| Security | High (no password to steal) | Medium (password can be weak) | -| UX | Simple (one click) | Traditional (remember password) | -| Setup | Requires email config | Works out of box | -| Use Case | Modern apps, high security | Traditional apps, offline access | -| Default | ❌ Inactive | ✅ Active | - -## Why Inactive by Default? - -Most developers expect traditional email/password authentication: - -1. **Familiarity**: Email/password is the standard -2. **Email dependency**: Requires email plugin setup -3. **Use case**: Better for modern, security-focused apps -4. **Offline**: Email/password works without email service - -Activate this plugin when you want passwordless authentication for better security and user experience. - -## License - -MIT diff --git a/packages/core/src/plugins/available/magic-link-auth/index.ts b/packages/core/src/plugins/available/magic-link-auth/index.ts deleted file mode 100644 index 43efecdeb..000000000 --- a/packages/core/src/plugins/available/magic-link-auth/index.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Magic Link Authentication Plugin - * - * Provides passwordless authentication via email magic links - * Users receive a secure one-time link to sign in without passwords - */ - -import { Hono } from 'hono' -import { z } from 'zod' -import type { Plugin, PluginContext } from '../../types' -import type { D1Database } from '@cloudflare/workers-types' -import { AuthManager } from '../../../middleware/auth' - -const magicLinkRequestSchema = z.object({ - email: z.string().email('Valid email is required') -}) - -export function createMagicLinkAuthPlugin(): Plugin { - const magicLinkRoutes = new Hono() - - // Request a magic link - magicLinkRoutes.post('/request', async (c: any) => { - try { - const body = await c.req.json() - const validation = magicLinkRequestSchema.safeParse(body) - - if (!validation.success) { - return c.json({ - error: 'Validation failed', - details: validation.error.issues - }, 400) - } - - const { email } = validation.data - const normalizedEmail = email.toLowerCase() - const db = c.env.DB as D1Database - - // Check rate limiting - const oneHourAgo = Date.now() - (60 * 60 * 1000) - const recentLinks = await db.prepare(` - SELECT COUNT(*) as count - FROM magic_links - WHERE user_email = ? AND created_at > ? - `).bind(normalizedEmail, oneHourAgo).first() as any - - const rateLimitPerHour = 5 // TODO: Get from plugin settings - if (recentLinks && recentLinks.count >= rateLimitPerHour) { - return c.json({ - error: 'Too many requests. Please try again later.' - }, 429) - } - - // Check if user exists - const user = await db.prepare(` - SELECT id, email, role, is_active - FROM users - WHERE email = ? - `).bind(normalizedEmail).first() as any - - const allowNewUsers = false // TODO: Get from plugin settings - - if (!user && !allowNewUsers) { - // Don't reveal if user exists or not for security - return c.json({ - message: 'If an account exists for this email, you will receive a magic link shortly.' - }) - } - - if (user && !user.is_active) { - return c.json({ - error: 'This account has been deactivated.' - }, 403) - } - - // Generate secure token - const token = crypto.randomUUID() + '-' + crypto.randomUUID() - const tokenId = crypto.randomUUID() - const linkExpiryMinutes = 15 // TODO: Get from plugin settings - const expiresAt = Date.now() + (linkExpiryMinutes * 60 * 1000) - - // Store magic link - await db.prepare(` - INSERT INTO magic_links ( - id, user_email, token, expires_at, used, created_at, ip_address, user_agent - ) VALUES (?, ?, ?, ?, 0, ?, ?, ?) - `).bind( - tokenId, - normalizedEmail, - token, - expiresAt, - Date.now(), - c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown', - c.req.header('user-agent') || 'unknown' - ).run() - - // Generate magic link URL - const baseUrl = new URL(c.req.url).origin - const magicLink = `${baseUrl}/auth/magic-link/verify?token=${token}` - - // Send email via email plugin - try { - const emailPlugin = c.env.plugins?.get('email') - if (emailPlugin && emailPlugin.sendEmail) { - await emailPlugin.sendEmail({ - to: normalizedEmail, - subject: 'Your Magic Link to Sign In', - html: renderMagicLinkEmail(magicLink, linkExpiryMinutes) - }) - } else { - console.error('Email plugin not available') - // In production, this should fail. For now, log the link for testing - console.log(`Magic link for ${normalizedEmail}: ${magicLink}`) - } - } catch (error) { - console.error('Failed to send magic link email:', error) - return c.json({ - error: 'Failed to send email. Please try again later.' - }, 500) - } - - return c.json({ - message: 'If an account exists for this email, you will receive a magic link shortly.', - // For development only - remove in production - ...(c.env.ENVIRONMENT === 'development' && { dev_link: magicLink }) - }) - } catch (error) { - console.error('Magic link request error:', error) - return c.json({ error: 'Failed to process request' }, 500) - } - }) - - // Verify magic link and sign in - magicLinkRoutes.get('/verify', async (c: any) => { - try { - const token = c.req.query('token') - - if (!token) { - return c.redirect('/auth/login?error=Invalid magic link') - } - - const db = c.env.DB as D1Database - - // Find magic link - const magicLink = await db.prepare(` - SELECT * FROM magic_links - WHERE token = ? AND used = 0 - `).bind(token).first() as any - - if (!magicLink) { - return c.redirect('/auth/login?error=Invalid or expired magic link') - } - - // Check expiration - if (magicLink.expires_at < Date.now()) { - return c.redirect('/auth/login?error=This magic link has expired') - } - - // Get or create user - let user = await db.prepare(` - SELECT * FROM users WHERE email = ? AND is_active = 1 - `).bind(magicLink.user_email).first() as any - - const allowNewUsers = false // TODO: Get from plugin settings - - if (!user && allowNewUsers) { - // Create new user - const userId = crypto.randomUUID() - const username = magicLink.user_email.split('@')[0] - const now = Date.now() - - await db.prepare(` - INSERT INTO users ( - id, email, username, first_name, last_name, - password_hash, role, is_active, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, NULL, 'viewer', 1, ?, ?) - `).bind( - userId, - magicLink.user_email, - username, - username, - '', - now, - now - ).run() - - user = { - id: userId, - email: magicLink.user_email, - username, - role: 'viewer' - } - } else if (!user) { - return c.redirect('/auth/login?error=No account found for this email') - } - - // Mark magic link as used - await db.prepare(` - UPDATE magic_links - SET used = 1, used_at = ? - WHERE id = ? - `).bind(Date.now(), magicLink.id).run() - - // Generate JWT token - const jwtToken = await AuthManager.generateToken( - user.id, - user.email, - user.role - ) - - // Set auth cookie - AuthManager.setAuthCookie(c, jwtToken) - - // Update last login - await db.prepare(` - UPDATE users SET last_login_at = ? WHERE id = ? - `).bind(Date.now(), user.id).run() - - // Redirect to admin dashboard - return c.redirect('/admin/dashboard?message=Successfully signed in') - } catch (error) { - console.error('Magic link verification error:', error) - return c.redirect('/auth/login?error=Authentication failed') - } - }) - - return { - name: 'magic-link-auth', - version: '1.0.0', - description: 'Passwordless authentication via email magic links', - author: { - name: 'SonicJS Team', - email: 'team@sonicjs.com' - }, - dependencies: ['email'], - - routes: [{ - path: '/auth/magic-link', - handler: magicLinkRoutes, - description: 'Magic link authentication endpoints', - requiresAuth: false - }], - - async install(context: PluginContext) { - console.log('Installing magic-link-auth plugin...') - // Migration is handled by plugin system - }, - - async activate(context: PluginContext) { - console.log('Magic link authentication activated') - console.log('Users can now sign in via /auth/magic-link/request') - }, - - async deactivate(context: PluginContext) { - console.log('Magic link authentication deactivated') - }, - - async uninstall(context: PluginContext) { - console.log('Uninstalling magic-link-auth plugin...') - // Optionally clean up magic_links table - // await context.db.prepare('DROP TABLE IF EXISTS magic_links').run() - } - } -} - -/** - * Render magic link email template - */ -function renderMagicLinkEmail(magicLink: string, expiryMinutes: number): string { - return ` - - - - - - Your Magic Link - - - -
-
-

🔗 Your Magic Link

-
- -
-

Hello!

-

You requested a magic link to sign in to your account. Click the button below to continue:

- -
- Sign In -
- -

⏰ This link expires in ${expiryMinutes} minutes

- -
- Security Notice: If you didn't request this link, you can safely ignore this email. - Someone may have entered your email address by mistake. -
-
- - -
- - - ` -} - -export default createMagicLinkAuthPlugin() diff --git a/packages/core/src/plugins/available/magic-link-auth/manifest.json b/packages/core/src/plugins/available/magic-link-auth/manifest.json deleted file mode 100644 index e586ab671..000000000 --- a/packages/core/src/plugins/available/magic-link-auth/manifest.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "id": "magic-link-auth", - "name": "Magic Link Authentication", - "version": "1.0.0", - "description": "Passwordless authentication via email magic links. Users receive a secure one-time link to sign in without entering a password.", - "author": { - "name": "SonicJS Team", - "email": "team@sonicjs.com" - }, - "category": "security", - "icon": "🔗", - "tags": ["authentication", "passwordless", "security", "email"], - "dependencies": ["email"], - "status": "inactive", - "enabled": false, - "license": "MIT", - "compatibility": "^2.0.0", - "settings": { - "linkExpiryMinutes": { - "type": "number", - "label": "Link Expiry (minutes)", - "default": 15, - "min": 5, - "max": 60, - "description": "How long magic links remain valid" - }, - "rateLimitPerHour": { - "type": "number", - "label": "Rate Limit (per hour)", - "default": 5, - "min": 1, - "max": 20, - "description": "Maximum magic link requests per email per hour" - }, - "allowNewUsers": { - "type": "boolean", - "label": "Allow New User Registration", - "default": false, - "description": "Allow new users to register via magic link (requires valid email)" - } - } -} diff --git a/packages/core/src/plugins/available/magic-link-auth/migration.sql b/packages/core/src/plugins/available/magic-link-auth/migration.sql deleted file mode 100644 index 55fb9f9ca..000000000 --- a/packages/core/src/plugins/available/magic-link-auth/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Magic Link Authentication Plugin Migration --- Creates table for storing magic link tokens - -CREATE TABLE IF NOT EXISTS magic_links ( - id TEXT PRIMARY KEY, - user_email TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at INTEGER NOT NULL, - used INTEGER DEFAULT 0, - used_at INTEGER, - ip_address TEXT, - user_agent TEXT, - created_at INTEGER NOT NULL, - INDEX idx_magic_links_token (token), - INDEX idx_magic_links_email (user_email), - INDEX idx_magic_links_expires (expires_at) -); diff --git a/packages/core/src/plugins/core-plugins/auth/index.ts b/packages/core/src/plugins/core-plugins/auth/index.ts index 1a9f19653..93f3c8944 100644 --- a/packages/core/src/plugins/core-plugins/auth/index.ts +++ b/packages/core/src/plugins/core-plugins/auth/index.ts @@ -25,36 +25,43 @@ export function createAuthPlugin(): Plugin { compatibility: '^0.1.0' }) - // Create auth API routes + // Create auth API routes (Better Auth owns sign-in/sign-out; this plugin exposes compatibility) const authAPI = new Hono() - // POST /auth/login - User login - authAPI.post('/login', async (c) => { - const { email } = await c.req.json() - - // Login logic would integrate with existing auth service - return c.json({ - message: 'Login endpoint', - data: { email } + // GET /api/auth/me - Current user from session (set by global middleware) + authAPI.get('/me', async (c) => { + const user = (c.get as (k: string) => { userId: string; email: string; role: string } | undefined)('user') + if (!user) { + return c.json({ error: 'Authentication required' }, 401) + } + return c.json({ + message: 'Current user info', + user: { id: user.userId, email: user.email, role: user.role } }) }) - // POST /auth/logout - User logout - authAPI.post('/logout', async (c) => { - return c.json({ message: 'Logout successful' }) + // POST /api/auth/login - Deprecated: use Better Auth POST /auth/sign-in/email + authAPI.post('/login', async (c) => { + return c.json( + { error: 'Use Better Auth: POST /auth/sign-in/email with { email, password }' }, + 410 + ) }) - // GET /auth/me - Current user info - authAPI.get('/me', async (c) => { - return c.json({ - message: 'Current user info', - user: { id: 1, email: 'user@example.com' } - }) + // POST /api/auth/logout - Deprecated: use Better Auth POST /auth/sign-out + authAPI.post('/logout', async (c) => { + return c.json( + { message: 'Use Better Auth: POST /auth/sign-out to sign out' }, + 200 + ) }) - // POST /auth/refresh - Refresh token + // POST /api/auth/refresh - Deprecated: Better Auth manages session refresh authAPI.post('/refresh', async (c) => { - return c.json({ message: 'Token refreshed' }) + return c.json( + { message: 'Session is managed by Better Auth; no separate refresh needed' }, + 200 + ) }) builder.addRoute('/api/auth', authAPI, { @@ -62,9 +69,8 @@ export function createAuthPlugin(): Plugin { priority: 1 }) - // Add auth middleware - builder.addSingleMiddleware('auth-session', async (c: any, next: any) => { - // Session management middleware + // Add auth middleware (session is set by global Better Auth middleware; this is for x-session-id only) + builder.addSingleMiddleware('auth-session', async (c: { req: { header: (name: string) => string | undefined }; set: (key: string, value: unknown) => void }, next: () => Promise) => { const sessionId = c.req.header('x-session-id') if (sessionId) { c.set('sessionId', sessionId) @@ -76,11 +82,8 @@ export function createAuthPlugin(): Plugin { priority: 5 }) - builder.addSingleMiddleware('auth-rate-limit', async (c: any, next: any) => { - // Rate limiting for auth endpoints - const path = c.req.path - if (path.startsWith('/api/auth/')) { - // Rate limiting logic would go here + builder.addSingleMiddleware('auth-rate-limit', async (c: { req: { path: string; header: (name: string) => string | undefined } }, next: () => Promise) => { + if (c.req.path.startsWith('/api/auth/')) { const clientIP = c.req.header('CF-Connecting-IP') || 'unknown' console.debug(`Auth rate limit check for IP: ${clientIP}`) } @@ -91,51 +94,35 @@ export function createAuthPlugin(): Plugin { priority: 3 }) - // Add auth service + // Add auth service (stub for plugin compatibility; main auth is Better Auth) builder.addService('authService', { - validateToken: (_token: string) => { - // Token validation logic - return { valid: true, userId: 1 } - }, - - generateToken: (userId: number) => { - // Token generation logic - return `token-${userId}-${Date.now()}` - }, - - hashPassword: (password: string) => { - // Password hashing logic - return `hashed-${password}` - }, - - verifyPassword: (password: string, hash: string) => { - // Password verification logic - return hash === `hashed-${password}` - } + validateToken: (_token: string) => ({ valid: false, userId: null as number | null }), + generateToken: (_userId: number) => '', + hashPassword: (_password: string) => '', + verifyPassword: (_password: string, _hash: string) => false }, { - description: 'Core authentication service', + description: 'Core authentication service (Better Auth is source of truth)', singleton: true }) // Add auth hooks - builder.addHook('auth:login', async (data: any) => { - console.info(`User login attempt: ${data.email}`) + builder.addHook('auth:login', async (data: { email?: string }) => { + console.info(`User login attempt: ${data.email ?? 'unknown'}`) return data }, { priority: 10, description: 'Handle user login events' }) - builder.addHook('auth:logout', async (data: any) => { - console.info(`User logout: ${data.userId}`) + builder.addHook('auth:logout', async (data: { userId?: string }) => { + console.info(`User logout: ${data.userId ?? 'unknown'}`) return data }, { priority: 10, description: 'Handle user logout events' }) - builder.addHook(HOOKS.REQUEST_START, async (data: any) => { - // Track authentication status on each request + builder.addHook(HOOKS.REQUEST_START, async (data: { request?: { headers?: { authorization?: string } }; authenticated?: boolean }) => { const authHeader = data.request?.headers?.authorization if (authHeader) { data.authenticated = true diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts index 9e70996c9..2e55c2ce2 100644 --- a/packages/core/src/plugins/core-plugins/index.ts +++ b/packages/core/src/plugins/core-plugins/index.ts @@ -19,7 +19,6 @@ export { databaseToolsPlugin } from './database-tools-plugin' export { helloWorldPlugin, createHelloWorldPlugin } from './hello-world-plugin' export { quillEditorPlugin, createQuillEditorPlugin } from './quill-editor' export { emailPlugin, createEmailPlugin } from './email-plugin' -export { otpLoginPlugin, createOTPLoginPlugin } from './otp-login-plugin' export { turnstilePlugin } from './turnstile-plugin' export { TurnstileService, verifyTurnstile, createTurnstileMiddleware } from './turnstile-plugin' export { aiSearchPlugin } from './ai-search-plugin' @@ -39,7 +38,6 @@ export const CORE_PLUGIN_IDS = [ 'hello-world', 'quill-editor', 'email', - 'otp-login', 'turnstile', 'ai-search' ] as const diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/README.md b/packages/core/src/plugins/core-plugins/otp-login-plugin/README.md deleted file mode 100644 index 6ce62df15..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# OTP Login Plugin - -Passwordless authentication for SonicJS via email one-time codes (OTP). Users sign in by entering a 6-digit code sent to their email - no password required. - -## Features - -- ✅ **Passwordless**: No passwords to remember or manage -- ✅ **Secure**: Crypto-secure random code generation -- ✅ **Rate Limited**: Prevents abuse (5 requests/hour default) -- ✅ **Brute Force Protection**: Max 3 attempts per code -- ✅ **Auto Expiry**: Codes expire after 10 minutes (configurable) -- ✅ **Mobile Friendly**: Easy to copy/paste codes -- ✅ **Email Integration**: Uses SonicJS email plugin - -## API Endpoints - -### Request OTP Code -```bash -POST /auth/otp/request -Content-Type: application/json - -{ - "email": "user@example.com" -} -``` - -### Verify OTP Code -```bash -POST /auth/otp/verify -Content-Type: application/json - -{ - "email": "user@example.com", - "code": "123456" -} -``` - -### Resend OTP Code -```bash -POST /auth/otp/resend -Content-Type: application/json - -{ - "email": "user@example.com" -} -``` - -## Configuration - -Settings available in Admin → Plugins → OTP Login → Settings: - -- **Code Length**: 4-8 digits (default: 6) -- **Code Expiry**: 5-60 minutes (default: 10) -- **Max Attempts**: 3-10 attempts (default: 3) -- **Rate Limit**: 3-20 requests/hour (default: 5) -- **Allow Registration**: Enable new user signup (default: false) - -## Security - -- Crypto-secure random generation -- One-time use codes -- Time-limited validity -- Rate limiting per email and IP -- Brute force protection -- IP and user agent logging - -## Development Mode - -In development (`ENVIRONMENT=development`), the API response includes the code: - -```json -{ - "message": "Code sent", - "dev_code": "123456" -} -``` - -## License - -MIT diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/email-templates.ts b/packages/core/src/plugins/core-plugins/otp-login-plugin/email-templates.ts deleted file mode 100644 index 950ab9a45..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/email-templates.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * OTP Email Templates - * HTML and plain text templates for OTP codes - */ - -export interface OTPEmailData { - code: string - expiryMinutes: number - codeLength: number - maxAttempts: number - email: string - ipAddress?: string - timestamp: string - appName: string - logoUrl?: string -} - -export function renderOTPEmailHTML(data: OTPEmailData): string { - return ` - - - - - Your Login Code - - - -
- - ${data.logoUrl ? ` -
- Logo -
- ` : ''} - -
-

Your Login Code

-

Enter this code to sign in to ${data.appName}

-
- -
-
-
- ${data.code} -
-
- -
-

- ⚠️ This code expires in ${data.expiryMinutes} minutes -

-
- -
-

Quick Tips:

-
    -
  • Enter the code exactly as shown (${data.codeLength} digits)
  • -
  • The code can only be used once
  • -
  • You have ${data.maxAttempts} attempts to enter the correct code
  • -
  • Request a new code if this one expires
  • -
-
- -
-

- 🔒 Security Notice -

-

- Never share this code with anyone. ${data.appName} will never ask you for this code via phone, email, or social media. -

-
-
- -
-

- Didn't request this code?
- Someone may have entered your email by mistake. You can safely ignore this email. -

- -
-

This email was sent to ${data.email}

- ${data.ipAddress ? `

IP Address: ${data.ipAddress}

` : ''} -

Time: ${data.timestamp}

-
-
- -
- -
-

© ${new Date().getFullYear()} ${data.appName}. All rights reserved.

-
- - -` -} - -export function renderOTPEmailText(data: OTPEmailData): string { - return `Your Login Code for ${data.appName} - -Your one-time verification code is: - -${data.code} - -This code expires in ${data.expiryMinutes} minutes. - -Quick Tips: -• Enter the code exactly as shown (${data.codeLength} digits) -• The code can only be used once -• You have ${data.maxAttempts} attempts to enter the correct code -• Request a new code if this one expires - -Security Notice: -Never share this code with anyone. ${data.appName} will never ask you for this code via phone, email, or social media. - -Didn't request this code? -Someone may have entered your email by mistake. You can safely ignore this email. - ---- -This email was sent to ${data.email} -${data.ipAddress ? `IP Address: ${data.ipAddress}` : ''} -Time: ${data.timestamp} - -© ${new Date().getFullYear()} ${data.appName}. All rights reserved.` -} - -export function renderOTPEmail(data: OTPEmailData): { html: string; text: string } { - return { - html: renderOTPEmailHTML(data), - text: renderOTPEmailText(data) - } -} diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts b/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts deleted file mode 100644 index b394c96a2..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * OTP Login Plugin - * - * Passwordless authentication via email one-time codes - * Users receive a secure 6-digit code to sign in without passwords - */ - -import { Hono } from 'hono' -import { setCookie } from 'hono/cookie' -import { z } from 'zod' -import { PluginBuilder } from '../../sdk/plugin-builder' -import type { Plugin } from '@sonicjs-cms/core' -import { OTPService, type OTPSettings } from './otp-service' -import { renderOTPEmail } from './email-templates' -import { AuthManager } from '../../../middleware' -import { SettingsService } from '../../../services/settings' - -// Validation schemas -const otpRequestSchema = z.object({ - email: z.string().email('Valid email is required') -}) - -const otpVerifySchema = z.object({ - email: z.string().email('Valid email is required'), - code: z.string().min(4).max(8) -}) - -// Default settings (site name comes from general settings) -const DEFAULT_SETTINGS: OTPSettings = { - codeLength: 6, - codeExpiryMinutes: 10, - maxAttempts: 3, - rateLimitPerHour: 5, - allowNewUserRegistration: false -} - -export function createOTPLoginPlugin(): Plugin { - const builder = PluginBuilder.create({ - name: 'otp-login', - version: '1.0.0-beta.1', - description: 'Passwordless authentication via email one-time codes' - }) - - builder.metadata({ - author: { - name: 'SonicJS Team', - email: 'team@sonicjs.com' - }, - license: 'MIT', - compatibility: '^2.0.0' - }) - - // ==================== API Routes ==================== - - const otpAPI = new Hono() - - // POST /auth/otp/request - Request OTP code - otpAPI.post('/request', async (c: any) => { - try { - const body = await c.req.json() - const validation = otpRequestSchema.safeParse(body) - - if (!validation.success) { - return c.json({ - error: 'Validation failed', - details: validation.error.issues - }, 400) - } - - const { email } = validation.data - const normalizedEmail = email.toLowerCase() - const db = c.env.DB - const otpService = new OTPService(db) - - // Load plugin settings from database - let settings: OTPSettings = { ...DEFAULT_SETTINGS } - const pluginRow = await db.prepare(` - SELECT settings FROM plugins WHERE id = 'otp-login' - `).first() as { settings: string | null } | null - if (pluginRow?.settings) { - try { - const savedSettings = JSON.parse(pluginRow.settings) - settings = { ...DEFAULT_SETTINGS, ...savedSettings } - } catch (e) { - console.warn('Failed to parse OTP plugin settings, using defaults') - } - } - - // Get site name from general settings - const settingsService = new SettingsService(db) - const generalSettings = await settingsService.getGeneralSettings() - const siteName = generalSettings.siteName - - // Check rate limiting - const canRequest = await otpService.checkRateLimit(normalizedEmail, settings) - if (!canRequest) { - return c.json({ - error: 'Too many requests. Please try again in an hour.' - }, 429) - } - - // Check if user exists - const user = await db.prepare(` - SELECT id, email, role, is_active - FROM users - WHERE email = ? - `).bind(normalizedEmail).first() as any - - if (!user && !settings.allowNewUserRegistration) { - // Don't reveal if user exists or not (security) - return c.json({ - message: 'If an account exists for this email, you will receive a verification code shortly.', - expiresIn: settings.codeExpiryMinutes * 60 - }) - } - - if (user && !user.is_active) { - return c.json({ - error: 'This account has been deactivated.' - }, 403) - } - - // Get IP and user agent - const ipAddress = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown' - const userAgent = c.req.header('user-agent') || 'unknown' - - // Create OTP code - const otpCode = await otpService.createOTPCode( - normalizedEmail, - settings, - ipAddress, - userAgent - ) - - // Send email via Email plugin - try { - const isDevMode = c.env.ENVIRONMENT === 'development' - - if (isDevMode) { - console.log(`[DEV] OTP Code for ${normalizedEmail}: ${otpCode.code}`) - } - - // Prepare email content - const emailContent = renderOTPEmail({ - code: otpCode.code, - expiryMinutes: settings.codeExpiryMinutes, - codeLength: settings.codeLength, - maxAttempts: settings.maxAttempts, - email: normalizedEmail, - ipAddress, - timestamp: new Date().toISOString(), - appName: siteName - }) - - // Load email plugin settings from database - // Note: We don't check status='active' because the email plugin's - // settings UI works regardless of status, so we follow the same pattern - const emailPlugin = await db.prepare(` - SELECT settings FROM plugins WHERE id = 'email' - `).first() as { settings: string | null } | null - - if (emailPlugin?.settings) { - const emailSettings = JSON.parse(emailPlugin.settings) - - if (emailSettings.apiKey && emailSettings.fromEmail && emailSettings.fromName) { - // Send email via Resend API - const emailResponse = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${emailSettings.apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from: `${emailSettings.fromName} <${emailSettings.fromEmail}>`, - to: [normalizedEmail], - subject: `Your login code for ${siteName}`, - html: emailContent.html, - text: emailContent.text, - reply_to: emailSettings.replyTo || emailSettings.fromEmail - }) - }) - - if (!emailResponse.ok) { - const errorData = await emailResponse.json() as { message?: string } - console.error('Failed to send OTP email via Resend:', errorData) - // Don't expose error to user for security - just log it - } - } else { - console.warn('Email plugin is not fully configured (missing apiKey, fromEmail, or fromName)') - } - } else { - console.warn('Email plugin is not active or has no settings configured') - } - - const response: any = { - message: 'If an account exists for this email, you will receive a verification code shortly.', - expiresIn: settings.codeExpiryMinutes * 60 - } - - // In development, include the code - if (isDevMode) { - response.dev_code = otpCode.code - } - - return c.json(response) - } catch (emailError) { - console.error('Error sending OTP email:', emailError) - return c.json({ - error: 'Failed to send verification code. Please try again.' - }, 500) - } - } catch (error) { - console.error('OTP request error:', error) - return c.json({ - error: 'An error occurred. Please try again.' - }, 500) - } - }) - - // POST /auth/otp/verify - Verify OTP code - otpAPI.post('/verify', async (c: any) => { - try { - const body = await c.req.json() - const validation = otpVerifySchema.safeParse(body) - - if (!validation.success) { - return c.json({ - error: 'Validation failed', - details: validation.error.issues - }, 400) - } - - const { email, code } = validation.data - const normalizedEmail = email.toLowerCase() - const db = c.env.DB - const otpService = new OTPService(db) - - // Load plugin settings from database - let settings = { ...DEFAULT_SETTINGS } - const pluginRow = await db.prepare(` - SELECT settings FROM plugins WHERE id = 'otp-login' - `).first() as { settings: string | null } | null - if (pluginRow?.settings) { - try { - const savedSettings = JSON.parse(pluginRow.settings) - settings = { ...DEFAULT_SETTINGS, ...savedSettings } - } catch (e) { - console.warn('Failed to parse OTP plugin settings, using defaults') - } - } - - // Verify the code - const verification = await otpService.verifyCode(normalizedEmail, code, settings) - - if (!verification.valid) { - // Increment attempts on failure - await otpService.incrementAttempts(normalizedEmail, code) - - return c.json({ - error: verification.error || 'Invalid code', - attemptsRemaining: verification.attemptsRemaining - }, 401) - } - - // Code is valid - get user - const user = await db.prepare(` - SELECT id, email, role, is_active - FROM users - WHERE email = ? - `).bind(normalizedEmail).first() as any - - if (!user) { - return c.json({ - error: 'User not found' - }, 404) - } - - if (!user.is_active) { - return c.json({ - error: 'Account is deactivated' - }, 403) - } - - // Generate JWT token - const token = await AuthManager.generateToken(user.id, user.email, user.role) - - // Set HTTP-only cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: true, - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - return c.json({ - success: true, - user: { - id: user.id, - email: user.email, - role: user.role - }, - token, - message: 'Authentication successful' - }) - } catch (error) { - console.error('OTP verify error:', error) - return c.json({ - error: 'An error occurred. Please try again.' - }, 500) - } - }) - - // POST /auth/otp/resend - Resend OTP code - otpAPI.post('/resend', async (c: any) => { - try { - const body = await c.req.json() - const validation = otpRequestSchema.safeParse(body) - - if (!validation.success) { - return c.json({ - error: 'Validation failed', - details: validation.error.issues - }, 400) - } - - // Reuse the request endpoint logic - return otpAPI.fetch( - new Request(c.req.url.replace('/resend', '/request'), { - method: 'POST', - headers: c.req.raw.headers, - body: JSON.stringify({ email: validation.data.email }) - }), - c.env - ) - } catch (error) { - console.error('OTP resend error:', error) - return c.json({ - error: 'An error occurred. Please try again.' - }, 500) - } - }) - - // Register API routes - builder.addRoute('/auth/otp', otpAPI, { - description: 'OTP authentication endpoints', - requiresAuth: false, - priority: 100 - }) - - // Note: Admin UI is now handled by the generic plugin settings page - // with custom component at admin-plugin-settings.template.ts - - // Add menu item (points to generic plugin settings page) - builder.addMenuItem('OTP Login', '/admin/plugins/otp-login', { - icon: 'key', - order: 85, - permissions: ['otp:manage'] - }) - - // Lifecycle hooks - builder.lifecycle({ - activate: async () => { - console.info('✅ OTP Login plugin activated') - }, - deactivate: async () => { - console.info('❌ OTP Login plugin deactivated') - } - }) - - return builder.build() as Plugin -} - -export const otpLoginPlugin = createOTPLoginPlugin() diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json b/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json deleted file mode 100644 index 66f536e50..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "id": "otp-login", - "name": "OTP Login", - "version": "1.0.0-beta.1", - "description": "Passwordless authentication via email one-time codes", - "author": "SonicJS Team", - "homepage": "https://sonicjs.com/plugins/otp-login", - "repository": "https://github.com/lane711/sonicjs/tree/main/src/plugins/core-plugins/otp-login-plugin", - "license": "MIT", - "category": "security", - "tags": ["otp", "passwordless", "authentication", "email", "2fa"], - "dependencies": ["email"], - "settings": { - "codeLength": { - "type": "number", - "label": "Code Length", - "default": 6, - "min": 4, - "max": 8, - "description": "Length of OTP code (4-8 digits)", - "required": false - }, - "codeExpiryMinutes": { - "type": "number", - "label": "Code Expiry (minutes)", - "default": 10, - "min": 5, - "max": 60, - "description": "How long codes remain valid", - "required": false - }, - "maxAttempts": { - "type": "number", - "label": "Maximum Attempts", - "default": 3, - "min": 3, - "max": 10, - "description": "Max verification attempts before code invalidation", - "required": false - }, - "rateLimitPerHour": { - "type": "number", - "label": "Rate Limit (per hour)", - "default": 5, - "min": 3, - "max": 20, - "description": "Max code requests per email per hour", - "required": false - }, - "allowNewUserRegistration": { - "type": "boolean", - "label": "Allow New User Registration", - "default": false, - "description": "Allow new users to register via OTP", - "required": false - } - }, - "hooks": { - "onActivate": "activate", - "onDeactivate": "deactivate" - }, - "routes": [], - "permissions": { - "otp:manage": "Manage OTP login settings", - "otp:request": "Request OTP codes", - "otp:verify": "Verify OTP codes" - }, - "adminMenu": { - "label": "OTP Login", - "icon": "key", - "path": "/admin/plugins/otp-login/settings", - "order": 85 - } -} diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.test.ts b/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.test.ts deleted file mode 100644 index ae48d365c..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -/** - * OTP Service Tests - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { OTPService, type OTPSettings, type OTPCode } from './otp-service' - -// Mock D1Database -function createMockDb() { - const mockPrepare = vi.fn() - const mockBind = vi.fn() - const mockFirst = vi.fn() - const mockAll = vi.fn() - const mockRun = vi.fn() - - const chainable = { - bind: mockBind.mockReturnThis(), - first: mockFirst, - all: mockAll, - run: mockRun - } - - mockPrepare.mockReturnValue(chainable) - - return { - prepare: mockPrepare, - _mocks: { - prepare: mockPrepare, - bind: mockBind, - first: mockFirst, - all: mockAll, - run: mockRun - } - } -} - -// Default OTP settings for tests -function createTestSettings(overrides: Partial = {}): OTPSettings { - return { - codeLength: 6, - codeExpiryMinutes: 10, - maxAttempts: 3, - rateLimitPerHour: 5, - allowNewUserRegistration: true, - ...overrides - } -} - -describe('OTPService', () => { - let otpService: OTPService - let mockDb: ReturnType - - beforeEach(() => { - mockDb = createMockDb() - otpService = new OTPService(mockDb as any) - vi.clearAllMocks() - }) - - describe('generateCode', () => { - it('should generate a code of specified length', () => { - const code = otpService.generateCode(6) - - expect(code).toHaveLength(6) - expect(/^\d+$/.test(code)).toBe(true) - }) - - it('should generate 6 digit code by default', () => { - const code = otpService.generateCode() - - expect(code).toHaveLength(6) - }) - - it('should generate different length codes', () => { - const code4 = otpService.generateCode(4) - const code8 = otpService.generateCode(8) - - expect(code4).toHaveLength(4) - expect(code8).toHaveLength(8) - }) - - it('should generate only numeric codes', () => { - // Generate multiple codes to ensure randomness produces valid output - for (let i = 0; i < 10; i++) { - const code = otpService.generateCode(6) - expect(/^[0-9]+$/.test(code)).toBe(true) - } - }) - - it('should generate different codes on subsequent calls', () => { - const codes = new Set() - for (let i = 0; i < 100; i++) { - codes.add(otpService.generateCode(6)) - } - // With 6 digit codes and 100 attempts, we should have many unique codes - expect(codes.size).toBeGreaterThan(90) - }) - }) - - describe('createOTPCode', () => { - it('should create and store an OTP code', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings() - - const result = await otpService.createOTPCode('test@example.com', settings) - - expect(result).toBeDefined() - expect(result.user_email).toBe('test@example.com') - expect(result.code).toHaveLength(6) - expect(result.used).toBe(0) - expect(result.attempts).toBe(0) - expect(mockDb._mocks.prepare).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO otp_codes')) - }) - - it('should normalize email to lowercase', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings() - - const result = await otpService.createOTPCode('TEST@EXAMPLE.COM', settings) - - expect(result.user_email).toBe('test@example.com') - }) - - it('should set expiry based on settings', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings({ codeExpiryMinutes: 15 }) - const beforeCreate = Date.now() - - const result = await otpService.createOTPCode('test@example.com', settings) - - const expectedExpiry = beforeCreate + (15 * 60 * 1000) - // Allow some tolerance for execution time - expect(result.expires_at).toBeGreaterThanOrEqual(expectedExpiry - 1000) - expect(result.expires_at).toBeLessThanOrEqual(expectedExpiry + 1000) - }) - - it('should store IP address when provided', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings() - - const result = await otpService.createOTPCode( - 'test@example.com', - settings, - '192.168.1.1' - ) - - expect(result.ip_address).toBe('192.168.1.1') - }) - - it('should store user agent when provided', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings() - - const result = await otpService.createOTPCode( - 'test@example.com', - settings, - undefined, - 'Mozilla/5.0' - ) - - expect(result.user_agent).toBe('Mozilla/5.0') - }) - - it('should use custom code length from settings', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings({ codeLength: 8 }) - - const result = await otpService.createOTPCode('test@example.com', settings) - - expect(result.code).toHaveLength(8) - }) - - it('should set null for optional fields when not provided', async () => { - mockDb._mocks.run.mockResolvedValue({ success: true }) - const settings = createTestSettings() - - const result = await otpService.createOTPCode('test@example.com', settings) - - expect(result.ip_address).toBeNull() - expect(result.user_agent).toBeNull() - expect(result.used_at).toBeNull() - }) - }) - - describe('verifyCode', () => { - it('should return valid: true for correct code', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue({ - id: 'otp-123', - user_email: 'test@example.com', - code: '123456', - expires_at: Date.now() + 60000, // Not expired - used: 0, - attempts: 0 - }) - mockDb._mocks.run.mockResolvedValue({ success: true }) - - const result = await otpService.verifyCode('test@example.com', '123456', settings) - - expect(result.valid).toBe(true) - expect(result.error).toBeUndefined() - }) - - it('should return invalid for non-existent code', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue(null) - - const result = await otpService.verifyCode('test@example.com', '000000', settings) - - expect(result.valid).toBe(false) - expect(result.error).toBe('Invalid or expired code') - }) - - it('should return invalid for expired code', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue({ - id: 'otp-123', - user_email: 'test@example.com', - code: '123456', - expires_at: Date.now() - 60000, // Expired - used: 0, - attempts: 0 - }) - - const result = await otpService.verifyCode('test@example.com', '123456', settings) - - expect(result.valid).toBe(false) - expect(result.error).toBe('Code has expired') - }) - - it('should return invalid when max attempts exceeded', async () => { - const settings = createTestSettings({ maxAttempts: 3 }) - mockDb._mocks.first.mockResolvedValue({ - id: 'otp-123', - user_email: 'test@example.com', - code: '123456', - expires_at: Date.now() + 60000, - used: 0, - attempts: 3 // Max attempts reached - }) - - const result = await otpService.verifyCode('test@example.com', '123456', settings) - - expect(result.valid).toBe(false) - expect(result.error).toBe('Maximum attempts exceeded') - }) - - it('should mark code as used after successful verification', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue({ - id: 'otp-123', - user_email: 'test@example.com', - code: '123456', - expires_at: Date.now() + 60000, - used: 0, - attempts: 0 - }) - mockDb._mocks.run.mockResolvedValue({ success: true }) - - await otpService.verifyCode('test@example.com', '123456', settings) - - expect(mockDb._mocks.prepare).toHaveBeenCalledWith( - expect.stringContaining('UPDATE otp_codes') - ) - expect(mockDb._mocks.prepare).toHaveBeenCalledWith( - expect.stringContaining('used = 1') - ) - }) - - it('should normalize email to lowercase', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue(null) - - await otpService.verifyCode('TEST@EXAMPLE.COM', '123456', settings) - - expect(mockDb._mocks.bind).toHaveBeenCalledWith('test@example.com', '123456') - }) - }) - - describe('incrementAttempts', () => { - it('should increment attempts and return new count', async () => { - mockDb._mocks.first.mockResolvedValue({ attempts: 2 }) - - const result = await otpService.incrementAttempts('test@example.com', '123456') - - expect(result).toBe(2) - expect(mockDb._mocks.prepare).toHaveBeenCalledWith( - expect.stringContaining('attempts = attempts + 1') - ) - }) - - it('should return 0 when no matching code found', async () => { - mockDb._mocks.first.mockResolvedValue(null) - - const result = await otpService.incrementAttempts('test@example.com', '123456') - - expect(result).toBe(0) - }) - - it('should normalize email to lowercase', async () => { - mockDb._mocks.first.mockResolvedValue({ attempts: 1 }) - - await otpService.incrementAttempts('TEST@EXAMPLE.COM', '123456') - - expect(mockDb._mocks.bind).toHaveBeenCalledWith('test@example.com', '123456') - }) - }) - - describe('checkRateLimit', () => { - it('should return true when under rate limit', async () => { - const settings = createTestSettings({ rateLimitPerHour: 5 }) - mockDb._mocks.first.mockResolvedValue({ count: 3 }) - - const result = await otpService.checkRateLimit('test@example.com', settings) - - expect(result).toBe(true) - }) - - it('should return false when at rate limit', async () => { - const settings = createTestSettings({ rateLimitPerHour: 5 }) - mockDb._mocks.first.mockResolvedValue({ count: 5 }) - - const result = await otpService.checkRateLimit('test@example.com', settings) - - expect(result).toBe(false) - }) - - it('should return false when over rate limit', async () => { - const settings = createTestSettings({ rateLimitPerHour: 5 }) - mockDb._mocks.first.mockResolvedValue({ count: 10 }) - - const result = await otpService.checkRateLimit('test@example.com', settings) - - expect(result).toBe(false) - }) - - it('should return true when count is 0', async () => { - const settings = createTestSettings({ rateLimitPerHour: 5 }) - mockDb._mocks.first.mockResolvedValue({ count: 0 }) - - const result = await otpService.checkRateLimit('test@example.com', settings) - - expect(result).toBe(true) - }) - - it('should return true when count is null', async () => { - const settings = createTestSettings({ rateLimitPerHour: 5 }) - mockDb._mocks.first.mockResolvedValue(null) - - const result = await otpService.checkRateLimit('test@example.com', settings) - - expect(result).toBe(true) - }) - - it('should normalize email to lowercase', async () => { - const settings = createTestSettings() - mockDb._mocks.first.mockResolvedValue({ count: 0 }) - - await otpService.checkRateLimit('TEST@EXAMPLE.COM', settings) - - expect(mockDb._mocks.bind).toHaveBeenCalledWith('test@example.com', expect.any(Number)) - }) - }) - - describe('getRecentRequests', () => { - it('should return recent OTP requests', async () => { - mockDb._mocks.all.mockResolvedValue({ - results: [ - { - id: 'otp-1', - user_email: 'user1@example.com', - code: '123456', - expires_at: Date.now() + 60000, - used: 0, - used_at: null, - ip_address: '192.168.1.1', - user_agent: 'Mozilla', - attempts: 0, - created_at: Date.now() - }, - { - id: 'otp-2', - user_email: 'user2@example.com', - code: '654321', - expires_at: Date.now() + 60000, - used: 1, - used_at: Date.now(), - ip_address: null, - user_agent: null, - attempts: 1, - created_at: Date.now() - 1000 - } - ] - }) - - const result = await otpService.getRecentRequests() - - expect(result).toHaveLength(2) - expect(result[0].user_email).toBe('user1@example.com') - expect(result[1].user_email).toBe('user2@example.com') - }) - - it('should use default limit of 50', async () => { - mockDb._mocks.all.mockResolvedValue({ results: [] }) - - await otpService.getRecentRequests() - - expect(mockDb._mocks.bind).toHaveBeenCalledWith(50) - }) - - it('should use custom limit when provided', async () => { - mockDb._mocks.all.mockResolvedValue({ results: [] }) - - await otpService.getRecentRequests(10) - - expect(mockDb._mocks.bind).toHaveBeenCalledWith(10) - }) - - it('should return empty array when no results', async () => { - mockDb._mocks.all.mockResolvedValue({ results: null }) - - const result = await otpService.getRecentRequests() - - expect(result).toEqual([]) - }) - - it('should handle missing optional fields', async () => { - mockDb._mocks.all.mockResolvedValue({ - results: [{ - id: 'otp-1', - user_email: 'user@example.com', - code: '123456', - // Missing optional fields - }] - }) - - const result = await otpService.getRecentRequests() - - expect(result[0].ip_address).toBeNull() - expect(result[0].user_agent).toBeNull() - expect(result[0].used_at).toBeNull() - }) - }) - - describe('cleanupExpiredCodes', () => { - it('should delete expired codes and return count', async () => { - mockDb._mocks.run.mockResolvedValue({ meta: { changes: 5 } }) - - const result = await otpService.cleanupExpiredCodes() - - expect(result).toBe(5) - expect(mockDb._mocks.prepare).toHaveBeenCalledWith( - expect.stringContaining('DELETE FROM otp_codes') - ) - }) - - it('should return 0 when no codes deleted', async () => { - mockDb._mocks.run.mockResolvedValue({ meta: { changes: 0 } }) - - const result = await otpService.cleanupExpiredCodes() - - expect(result).toBe(0) - }) - - it('should return 0 when meta.changes is undefined', async () => { - mockDb._mocks.run.mockResolvedValue({ meta: {} }) - - const result = await otpService.cleanupExpiredCodes() - - expect(result).toBe(0) - }) - }) - - describe('getStats', () => { - it('should return OTP statistics', async () => { - mockDb._mocks.first.mockResolvedValue({ - total: 100, - successful: 80, - failed: 10, - expired: 10 - }) - - const result = await otpService.getStats() - - expect(result).toEqual({ - total: 100, - successful: 80, - failed: 10, - expired: 10 - }) - }) - - it('should use default 7 days when not specified', async () => { - mockDb._mocks.first.mockResolvedValue({ - total: 0, - successful: 0, - failed: 0, - expired: 0 - }) - - const beforeCall = Date.now() - await otpService.getStats() - - // Second bind argument should be approximately 7 days ago - const sevenDaysMs = 7 * 24 * 60 * 60 * 1000 - const expectedSince = beforeCall - sevenDaysMs - - expect(mockDb._mocks.bind).toHaveBeenCalled() - const bindArgs = mockDb._mocks.bind.mock.calls[0] - expect(bindArgs[1]).toBeGreaterThanOrEqual(expectedSince - 1000) - expect(bindArgs[1]).toBeLessThanOrEqual(expectedSince + 1000) - }) - - it('should use custom days parameter', async () => { - mockDb._mocks.first.mockResolvedValue({ - total: 0, - successful: 0, - failed: 0, - expired: 0 - }) - - const beforeCall = Date.now() - await otpService.getStats(30) - - const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000 - const expectedSince = beforeCall - thirtyDaysMs - - expect(mockDb._mocks.bind).toHaveBeenCalled() - const bindArgs = mockDb._mocks.bind.mock.calls[0] - expect(bindArgs[1]).toBeGreaterThanOrEqual(expectedSince - 1000) - expect(bindArgs[1]).toBeLessThanOrEqual(expectedSince + 1000) - }) - - it('should return zeros when no results', async () => { - mockDb._mocks.first.mockResolvedValue(null) - - const result = await otpService.getStats() - - expect(result).toEqual({ - total: 0, - successful: 0, - failed: 0, - expired: 0 - }) - }) - - it('should handle partial results', async () => { - mockDb._mocks.first.mockResolvedValue({ - total: 50, - // Missing other fields - }) - - const result = await otpService.getStats() - - expect(result.total).toBe(50) - expect(result.successful).toBe(0) - expect(result.failed).toBe(0) - expect(result.expired).toBe(0) - }) - }) -}) diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.ts b/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.ts deleted file mode 100644 index be8458cc1..000000000 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/otp-service.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * OTP Service - * Handles OTP code generation, verification, and management - */ - -import type { D1Database } from '@cloudflare/workers-types' - -export interface OTPSettings { - codeLength: number - codeExpiryMinutes: number - maxAttempts: number - rateLimitPerHour: number - allowNewUserRegistration: boolean -} - -export interface OTPCode { - id: string - user_email: string - code: string - expires_at: number - used: number - used_at: number | null - ip_address: string | null - user_agent: string | null - attempts: number - created_at: number -} - -export class OTPService { - constructor(private db: D1Database) {} - - /** - * Generate a secure random OTP code - */ - generateCode(length: number = 6): string { - const digits = '0123456789' - let code = '' - - for (let i = 0; i < length; i++) { - const randomValues = new Uint8Array(1) - crypto.getRandomValues(randomValues) - const randomValue = randomValues[0] ?? 0 - code += digits[randomValue % digits.length] - } - - return code - } - - /** - * Create and store a new OTP code - */ - async createOTPCode( - email: string, - settings: OTPSettings, - ipAddress?: string, - userAgent?: string - ): Promise { - const code = this.generateCode(settings.codeLength) - const id = crypto.randomUUID() - const now = Date.now() - const expiresAt = now + (settings.codeExpiryMinutes * 60 * 1000) - - const otpCode: OTPCode = { - id, - user_email: email.toLowerCase(), - code, - expires_at: expiresAt, - used: 0, - used_at: null, - ip_address: ipAddress || null, - user_agent: userAgent || null, - attempts: 0, - created_at: now - } - - await this.db.prepare(` - INSERT INTO otp_codes ( - id, user_email, code, expires_at, used, used_at, - ip_address, user_agent, attempts, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).bind( - otpCode.id, - otpCode.user_email, - otpCode.code, - otpCode.expires_at, - otpCode.used, - otpCode.used_at, - otpCode.ip_address, - otpCode.user_agent, - otpCode.attempts, - otpCode.created_at - ).run() - - return otpCode - } - - /** - * Verify an OTP code - */ - async verifyCode( - email: string, - code: string, - settings: OTPSettings - ): Promise<{ valid: boolean; attemptsRemaining?: number; error?: string }> { - const normalizedEmail = email.toLowerCase() - const now = Date.now() - - // Find the most recent unused code for this email - const otpCode = await this.db.prepare(` - SELECT * FROM otp_codes - WHERE user_email = ? AND code = ? AND used = 0 - ORDER BY created_at DESC - LIMIT 1 - `).bind(normalizedEmail, code).first() as OTPCode | null - - if (!otpCode) { - return { valid: false, error: 'Invalid or expired code' } - } - - // Check if expired - if (now > otpCode.expires_at) { - return { valid: false, error: 'Code has expired' } - } - - // Check attempts - if (otpCode.attempts >= settings.maxAttempts) { - return { valid: false, error: 'Maximum attempts exceeded' } - } - - // Code is valid - mark as used - await this.db.prepare(` - UPDATE otp_codes - SET used = 1, used_at = ?, attempts = attempts + 1 - WHERE id = ? - `).bind(now, otpCode.id).run() - - return { valid: true } - } - - /** - * Increment failed attempt count - */ - async incrementAttempts(email: string, code: string): Promise { - const normalizedEmail = email.toLowerCase() - - const result = await this.db.prepare(` - UPDATE otp_codes - SET attempts = attempts + 1 - WHERE user_email = ? AND code = ? AND used = 0 - RETURNING attempts - `).bind(normalizedEmail, code).first() as { attempts: number } | null - - return result?.attempts || 0 - } - - /** - * Check rate limiting - */ - async checkRateLimit(email: string, settings: OTPSettings): Promise { - const normalizedEmail = email.toLowerCase() - const oneHourAgo = Date.now() - (60 * 60 * 1000) - - const result = await this.db.prepare(` - SELECT COUNT(*) as count - FROM otp_codes - WHERE user_email = ? AND created_at > ? - `).bind(normalizedEmail, oneHourAgo).first() as { count: number } | null - - const count = result?.count || 0 - return count < settings.rateLimitPerHour - } - - /** - * Get recent OTP requests for activity log - */ - async getRecentRequests(limit: number = 50): Promise { - const result = await this.db.prepare(` - SELECT * FROM otp_codes - ORDER BY created_at DESC - LIMIT ? - `).bind(limit).all() - - const rows = (result.results || []) as Record[] - return rows.map(row => this.mapRowToOTP(row)) - } - - /** - * Clean up expired codes (for maintenance) - */ - async cleanupExpiredCodes(): Promise { - const now = Date.now() - - const result = await this.db.prepare(` - DELETE FROM otp_codes - WHERE expires_at < ? OR (used = 1 AND used_at < ?) - `).bind(now, now - (30 * 24 * 60 * 60 * 1000)).run() // Keep used codes for 30 days - - return result.meta.changes || 0 - } - - private mapRowToOTP(row: Record): OTPCode { - return { - id: String(row.id), - user_email: String(row.user_email), - code: String(row.code), - expires_at: Number(row.expires_at ?? Date.now()), - used: Number(row.used ?? 0), - used_at: row.used_at === null || row.used_at === undefined ? null : Number(row.used_at), - ip_address: typeof row.ip_address === 'string' ? row.ip_address : null, - user_agent: typeof row.user_agent === 'string' ? row.user_agent : null, - attempts: Number(row.attempts ?? 0), - created_at: Number(row.created_at ?? Date.now()) - } - } - - /** - * Get OTP statistics - */ - async getStats(days: number = 7): Promise<{ - total: number - successful: number - failed: number - expired: number - }> { - const since = Date.now() - (days * 24 * 60 * 60 * 1000) - - const stats = await this.db.prepare(` - SELECT - COUNT(*) as total, - SUM(CASE WHEN used = 1 THEN 1 ELSE 0 END) as successful, - SUM(CASE WHEN attempts >= 3 AND used = 0 THEN 1 ELSE 0 END) as failed, - SUM(CASE WHEN expires_at < ? AND used = 0 THEN 1 ELSE 0 END) as expired - FROM otp_codes - WHERE created_at > ? - `).bind(Date.now(), since).first() as any - - return { - total: stats?.total || 0, - successful: stats?.successful || 0, - failed: stats?.failed || 0, - expired: stats?.expired || 0 - } - } -} diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index 2e00ec200..2e6b2f0aa 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -258,42 +258,7 @@ adminPluginRoutes.get('/:id', async (c) => { const activity = await pluginService.getPluginActivity(pluginId, 20) // Load additional context for plugins with custom settings components - let enrichedSettings = plugin.settings || {} - - // For OTP Login plugin, add site name and email config status - if (pluginId === 'otp-login') { - // Get site name from general settings - const generalSettings = await db.prepare(` - SELECT value FROM settings WHERE key = 'general' - `).first() as { value: string } | null - - let siteName = 'SonicJS' - if (generalSettings?.value) { - try { - const parsed = JSON.parse(generalSettings.value) - siteName = parsed.siteName || 'SonicJS' - } catch (e) { /* ignore */ } - } - - // Check if email plugin is configured - const emailPlugin = await db.prepare(` - SELECT settings FROM plugins WHERE id = 'email' - `).first() as { settings: string | null } | null - - let emailConfigured = false - if (emailPlugin?.settings) { - try { - const emailSettings = JSON.parse(emailPlugin.settings) - emailConfigured = !!(emailSettings.apiKey && emailSettings.fromEmail && emailSettings.fromName) - } catch (e) { /* ignore */ } - } - - enrichedSettings = { - ...enrichedSettings, - siteName, - _emailConfigured: emailConfigured - } - } + const enrichedSettings = plugin.settings || {} // Map plugin data to template format const templatePlugin = { diff --git a/packages/core/src/routes/auth.ts b/packages/core/src/routes/auth.ts index c36513ff2..b37144c56 100644 --- a/packages/core/src/routes/auth.ts +++ b/packages/core/src/routes/auth.ts @@ -1,14 +1,10 @@ import { Hono } from 'hono' -// import { zValidator } from '@hono/zod-validator' -import { z } from 'zod' import { setCookie } from 'hono/cookie' import { html } from 'hono/html' import { AuthManager, requireAuth } from '../middleware' import { renderLoginPage, LoginPageData } from '../templates/pages/auth-login.template' import { renderRegisterPage, RegisterPageData } from '../templates/pages/auth-register.template' -import { getCacheService, CACHE_CONFIGS } from '../services' -import { authValidationService, isRegistrationEnabled, isFirstUserRegistration } from '../services/auth-validation' -import type { RegistrationData } from '../services/auth-validation' +import { isRegistrationEnabled, isFirstUserRegistration } from '../services/auth-validation' import type { Bindings, Variables } from '../app' const authRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() @@ -63,514 +59,83 @@ authRoutes.get('/register', async (c) => { return c.html(renderRegisterPage(pageData)) }) -// Login schema -const loginSchema = z.object({ - email: z.string().email('Valid email is required'), - password: z.string().min(1, 'Password is required') -}) - -// Register new user -authRoutes.post('/register', - async (c) => { - try { - const db = c.env.DB - - // Check if this is the first user (bootstrap scenario) - always allow - const isFirstUser = await isFirstUserRegistration(db) - - // If not first user, check if registration is enabled - if (!isFirstUser) { - const registrationEnabled = await isRegistrationEnabled(db) - if (!registrationEnabled) { - return c.json({ error: 'Registration is currently disabled' }, 403) - } - } - - // Parse JSON with error handling - let requestData - try { - requestData = await c.req.json() - } catch (parseError) { - return c.json({ error: 'Invalid JSON in request body' }, 400) - } - - // Build and validate using dynamic schema - const validationSchema = await authValidationService.buildRegistrationSchema(db) - - let validatedData: RegistrationData - try { - validatedData = await validationSchema.parseAsync(requestData) - } catch (validationError: any) { - return c.json({ - error: 'Validation failed', - details: validationError.issues?.map((e: any) => e.message) || [validationError.message || 'Invalid request data'] - }, 400) - } - - // Extract fields with defaults for optional ones - const email = validatedData.email - const password = validatedData.password - const username = validatedData.username || authValidationService.generateDefaultValue('username', validatedData) - const firstName = validatedData.firstName || authValidationService.generateDefaultValue('firstName', validatedData) - const lastName = validatedData.lastName || authValidationService.generateDefaultValue('lastName', validatedData) - - // Normalize email to lowercase - const normalizedEmail = email.toLowerCase() - - // Check if user already exists - const existingUser = await db.prepare('SELECT id FROM users WHERE email = ? OR username = ?') - .bind(normalizedEmail, username) - .first() - - if (existingUser) { - return c.json({ error: 'User with this email or username already exists' }, 400) - } - - // Hash password - const passwordHash = await AuthManager.hashPassword(password) - - // Create user - const userId = crypto.randomUUID() - const now = new Date() - - await db.prepare(` - INSERT INTO users (id, email, username, first_name, last_name, password_hash, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).bind( - userId, - normalizedEmail, - username, - firstName, - lastName, - passwordHash, - 'viewer', // Default role - 1, // is_active - now.getTime(), - now.getTime() - ).run() - - // Generate JWT token - const token = await AuthManager.generateToken(userId, normalizedEmail, 'viewer') - - // Set HTTP-only cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: true, - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - return c.json({ - user: { - id: userId, - email: normalizedEmail, - username, - firstName, - lastName, - role: 'viewer' - }, - token - }, 201) - } catch (error) { - console.error('Registration error:', error) - // Return validation errors as 400, other errors as 500 - if (error instanceof Error && error.message.includes('validation')) { - return c.json({ error: error.message }, 400) - } - return c.json({ - error: 'Registration failed', - details: error instanceof Error ? error.message : String(error) - }, 500) - } - } +// Legacy POST /login and /register: use Better Auth endpoints instead +authRoutes.post('/login', (c) => + c.json({ error: 'Use POST /auth/sign-in/email with JSON { email, password }' }, 410) +) +authRoutes.post('/register', (c) => + c.json({ error: 'Use POST /auth/sign-up/email with JSON { email, password, name }' }, 410) ) -// Login user -authRoutes.post('/login', async (c) => { - try { - const body = await c.req.json() - const validation = loginSchema.safeParse(body) - if (!validation.success) { - return c.json({ error: 'Validation failed', details: validation.error.issues }, 400) - } - const { email, password } = validation.data - const db = c.env.DB - - // Normalize email to lowercase - const normalizedEmail = email.toLowerCase() - - // Find user with caching - const cache = getCacheService(CACHE_CONFIGS.user!) - let user = await cache.get(cache.generateKey('user', `email:${normalizedEmail}`)) - - if (!user) { - user = await db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1') - .bind(normalizedEmail) - .first() as any - - if (user) { - // Cache the user for faster subsequent lookups - await cache.set(cache.generateKey('user', `email:${normalizedEmail}`), user) - await cache.set(cache.generateKey('user', user.id), user) - } - } - - if (!user) { - return c.json({ error: 'Invalid email or password' }, 401) - } - - // Verify password - const isValidPassword = await AuthManager.verifyPassword(password, user.password_hash) - if (!isValidPassword) { - return c.json({ error: 'Invalid email or password' }, 401) - } - - // Generate JWT token - const token = await AuthManager.generateToken(user.id, user.email, user.role) - - // Set HTTP-only cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: true, - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - // Update last login - await db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?') - .bind(new Date().getTime(), user.id) - .run() - - // Invalidate user cache on login - await cache.delete(cache.generateKey('user', user.id)) - await cache.delete(cache.generateKey('user', `email:${normalizedEmail}`)) - - return c.json({ - user: { - id: user.id, - email: user.email, - username: user.username, - firstName: user.first_name, - lastName: user.last_name, - role: user.role - }, - token - }) - } catch (error) { - console.error('Login error:', error) - return c.json({ error: 'Login failed' }, 500) - } +// Logout: redirect to Better Auth sign-out then to login +authRoutes.get('/logout', (c) => { + return c.html(html` + + Signing out... + +

Signing out...

+ + + `) }) -// Logout user (both GET and POST for convenience) authRoutes.post('/logout', (c) => { - // Clear the auth cookie - setCookie(c, 'auth_token', '', { - httpOnly: true, - secure: false, // Set to true in production with HTTPS - sameSite: 'Strict', - maxAge: 0 // Expire immediately - }) - - return c.json({ message: 'Logged out successfully' }) + return c.html(html` + + Signing out... + +

Signing out...

+ + + `) }) -authRoutes.get('/logout', (c) => { - // Clear the auth cookie - setCookie(c, 'auth_token', '', { - httpOnly: true, - secure: false, // Set to true in production with HTTPS - sameSite: 'Strict', - maxAge: 0 // Expire immediately - }) - - return c.redirect('/auth/login?message=You have been logged out successfully') -}) - -// Get current user +// Get current user (session set by Better Auth middleware) authRoutes.get('/me', requireAuth(), async (c) => { - try { - // This would need the auth middleware applied - const user = c.get('user') - - if (!user) { - return c.json({ error: 'Not authenticated' }, 401) - } - - const db = c.env.DB - const userData = await db.prepare('SELECT id, email, username, first_name, last_name, role, created_at FROM users WHERE id = ?') - .bind(user.userId) - .first() - - if (!userData) { - return c.json({ error: 'User not found' }, 404) - } - - return c.json({ user: userData }) - } catch (error) { - console.error('Get user error:', error) - return c.json({ error: 'Failed to get user' }, 500) - } -}) - -// Refresh token -authRoutes.post('/refresh', requireAuth(), async (c) => { - try { - const user = c.get('user') - - if (!user) { - return c.json({ error: 'Not authenticated' }, 401) - } - - // Generate new token - const token = await AuthManager.generateToken(user.userId, user.email, user.role) - - // Set new cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: true, - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - return c.json({ token }) - } catch (error) { - console.error('Token refresh error:', error) - return c.json({ error: 'Token refresh failed' }, 500) - } -}) - -// Form-based registration handler (for HTML forms) -authRoutes.post('/register/form', async (c) => { - try { - const db = c.env.DB - - // Check if this is the first user (bootstrap scenario) - always allow - const isFirstUser = await isFirstUserRegistration(db) - - // If not first user, check if registration is enabled - if (!isFirstUser) { - const registrationEnabled = await isRegistrationEnabled(db) - if (!registrationEnabled) { - return c.html(html` -
- Registration is currently disabled. Please contact an administrator. -
- `) - } - } - - const formData = await c.req.formData() - - // Extract form data - const requestData = { - email: formData.get('email') as string, - password: formData.get('password') as string, - username: formData.get('username') as string, - firstName: formData.get('firstName') as string, - lastName: formData.get('lastName') as string, - } - - // Normalize email to lowercase - const normalizedEmail = requestData.email?.toLowerCase() - requestData.email = normalizedEmail - - // Build and validate using dynamic schema - const validationSchema = await authValidationService.buildRegistrationSchema(db) - const validation = await validationSchema.safeParseAsync(requestData) - - if (!validation.success) { - return c.html(html` -
- ${validation.error.issues.map((err: { message: string }) => err.message).join(', ')} -
- `) - } - - const validatedData: RegistrationData = validation.data - - // Extract fields with defaults for optional ones - // const email = validatedData.email - const password = validatedData.password - const username = validatedData.username || authValidationService.generateDefaultValue('username', validatedData) - const firstName = validatedData.firstName || authValidationService.generateDefaultValue('firstName', validatedData) - const lastName = validatedData.lastName || authValidationService.generateDefaultValue('lastName', validatedData) - - // Check if user already exists - const existingUser = await db.prepare('SELECT id FROM users WHERE email = ? OR username = ?') - .bind(normalizedEmail, username) - .first() - - if (existingUser) { - return c.html(html` -
- User with this email or username already exists -
- `) - } - - // Hash password - const passwordHash = await AuthManager.hashPassword(password) - - // Determine role: first user gets admin, others get viewer - const role = isFirstUser ? 'admin' : 'viewer' - - // Create user - const userId = crypto.randomUUID() - const now = new Date() - - await db.prepare(` - INSERT INTO users (id, email, username, first_name, last_name, password_hash, role, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).bind( - userId, - normalizedEmail, - username, - firstName, - lastName, - passwordHash, - role, - 1, // is_active - now.getTime(), - now.getTime() - ).run() - - // Generate JWT token - const token = await AuthManager.generateToken(userId, normalizedEmail, role) - - // Set HTTP-only cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: false, // Set to true in production with HTTPS - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - // Redirect based on role - const redirectUrl = role === 'admin' ? '/admin/dashboard' : '/admin/dashboard' - - return c.html(html` -
- Account created successfully! Redirecting... - -
- `) - } catch (error) { - console.error('Registration error:', error) - return c.html(html` -
- Registration failed. Please try again. -
- `) - } + const user = c.get('user') + if (!user) return c.json({ error: 'Not authenticated' }, 401) + const db = c.env.DB + const userData = await db + .prepare('SELECT id, email, username, first_name, last_name, role, created_at FROM users WHERE id = ?') + .bind(user.userId) + .first() + if (!userData) return c.json({ error: 'User not found' }, 404) + return c.json({ user: userData }) }) -// Form-based login handler (for HTML forms) -authRoutes.post('/login/form', async (c) => { - try { - const formData = await c.req.formData() - const email = formData.get('email') as string - const password = formData.get('password') as string - - // Normalize email to lowercase - const normalizedEmail = email.toLowerCase() - - // Validate the data - const validation = loginSchema.safeParse({ email: normalizedEmail, password }) - - if (!validation.success) { - return c.html(html` -
- ${validation.error.issues.map((err: { message: string }) => err.message).join(', ')} -
- `) - } +// Session refresh is handled by Better Auth (session.updateAge) +authRoutes.post('/refresh', requireAuth(), (c) => + c.json({ message: 'Session is refreshed automatically by Better Auth' }, 200) +) - const db = c.env.DB - - // Find user - const user = await db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1') - .bind(normalizedEmail) - .first() as any - - if (!user) { - return c.html(html` -
- Invalid email or password -
- `) - } - - // Verify password - const isValidPassword = await AuthManager.verifyPassword(password, user.password_hash) - if (!isValidPassword) { - return c.html(html` -
- Invalid email or password -
- `) - } - - // Generate JWT token - const token = await AuthManager.generateToken(user.id, user.email, user.role) - - // Set HTTP-only cookie - setCookie(c, 'auth_token', token, { - httpOnly: true, - secure: false, // Set to true in production with HTTPS - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - // Update last login - await db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?') - .bind(new Date().getTime(), user.id) - .run() - - return c.html(html` -
-
-
- - - -
-

Login successful! Redirecting to admin dashboard...

-
-
- -
-
- `) - } catch (error) { - console.error('Login error:', error) - return c.html(html` -
- Login failed. Please try again. -
- `) - } -}) +// Form handlers: login/register now submit to Better Auth via JS on the page +authRoutes.post('/register/form', (c) => + c.html(html`
Form now uses Better Auth. Submit again.
`) +) +authRoutes.post('/login/form', (c) => + c.html(html`
Form now uses Better Auth. Submit again.
`) +) // Test seeding endpoint (only for development/testing) authRoutes.post('/seed-admin', async (c) => { try { const db = c.env.DB - // First ensure the users table exists + // First ensure the users table exists (includes name for Better Auth) await db.prepare(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, + name TEXT, first_name TEXT NOT NULL, last_name TEXT NOT NULL, password_hash TEXT, diff --git a/packages/core/src/templates/pages/admin-plugin-settings.template.ts b/packages/core/src/templates/pages/admin-plugin-settings.template.ts index c022490c2..e2a274c0f 100644 --- a/packages/core/src/templates/pages/admin-plugin-settings.template.ts +++ b/packages/core/src/templates/pages/admin-plugin-settings.template.ts @@ -702,345 +702,9 @@ function formatTimestamp(timestamp: number): string { type PluginSettingsRenderer = (plugin: any, settings: PluginSettings) => string const pluginSettingsComponents: Record = { - 'otp-login': renderOTPLoginSettingsContent, 'email': renderEmailSettingsContent, } -/** - * OTP Login plugin settings content - */ -function renderOTPLoginSettingsContent(plugin: any, settings: PluginSettings): string { - const siteName = settings.siteName || 'SonicJS' - const emailConfigured = settings._emailConfigured || false - const codeLength = settings.codeLength || 6 - const codeExpiryMinutes = settings.codeExpiryMinutes || 10 - const maxAttempts = settings.maxAttempts || 3 - const rateLimitPerHour = settings.rateLimitPerHour || 5 - const allowNewUserRegistration = settings.allowNewUserRegistration || false - - return ` -
- -
-

- 📧 Test OTP Email -

- - ${!emailConfigured ? ` -
-

- ⚠️ Email not configured. - Configure the Email plugin - to send real emails. Dev mode will show codes in the response. -

-
- ` : ` -
-

- ✅ Email configured. Test emails will be sent via Resend. -

-
- `} - -
-
- - -
- - -
- - - - - -
- - -
-

Code Settings

- -
-
-
- - -

Number of digits (4-8)

-
- -
- - -

How long codes remain valid

-
- -
- - -

Max verification attempts

-
- -
- - -

Max requests per email per hour

-
-
- -
- - -
-
-
- - -
-

- 👁️ Email Preview -

-

- This is how the OTP email will appear to users. The site name "${siteName}" is configured in - General Settings. -

- -
-
-

Your Login Code

-

Enter this code to sign in to ${siteName}

-
- -
-
-
- 123456 -
-
- -
-

- ⚠️ This code expires in ${codeExpiryMinutes} minutes -

-
- -
-

- 🔒 Security Notice -

-

- Never share this code with anyone. ${siteName} will never ask you for this code via phone, email, or social media. -

-
-
-
-
- - -
-

🔢 Features

-
    -
  • ✓ Passwordless authentication
  • -
  • ✓ Secure random code generation
  • -
  • ✓ Rate limiting protection
  • -
  • ✓ Brute force prevention
  • -
  • ✓ Mobile-friendly UX
  • -
-
- - - -
- - - ` -} - /** * Email plugin settings content */ diff --git a/packages/core/src/templates/pages/auth-login.template.ts b/packages/core/src/templates/pages/auth-login.template.ts index 4ecc5f8e8..d0f33ed45 100644 --- a/packages/core/src/templates/pages/auth-login.template.ts +++ b/packages/core/src/templates/pages/auth-login.template.ts @@ -65,15 +65,14 @@ export function renderLoginPage(data: LoginPageData, demoLoginActive: boolean = ${data.error ? `
${renderAlert({ type: 'error', message: data.error })}
` : ''} ${data.message ? `
${renderAlert({ type: 'success', message: data.message })}
` : ''} - +
- +
@@ -116,6 +115,29 @@ export function renderLoginPage(data: LoginPageData, demoLoginActive: boolean = Sign In
+
diff --git a/packages/core/src/templates/pages/auth-register.template.ts b/packages/core/src/templates/pages/auth-register.template.ts index 06d50d424..7a8be345d 100644 --- a/packages/core/src/templates/pages/auth-register.template.ts +++ b/packages/core/src/templates/pages/auth-register.template.ts @@ -50,12 +50,14 @@ export function renderRegisterPage(data: RegisterPageData): string { ${data.error ? `
${renderAlert({ type: 'error', message: data.error })}
` : ''} - + +
+ +
@@ -144,6 +146,33 @@ export function renderRegisterPage(data: RegisterPageData): string { Create Account
+
@@ -153,7 +182,6 @@ export function renderRegisterPage(data: RegisterPageData): string {

-
diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 462b2b2df..32604fb88 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -162,6 +162,9 @@ After creation, you may want to set up environment variables: ```bash # .dev.vars (for local development) ENVIRONMENT=development +# Better Auth (required for login/sign-up) +BETTER_AUTH_SECRET=your-min-32-char-secret-change-in-production +BETTER_AUTH_URL=http://localhost:8787 ``` ## Cloudflare Resources diff --git a/tests/e2e/02-authentication.spec.ts b/tests/e2e/02-authentication.spec.ts index 74005d7ac..9fac71155 100644 --- a/tests/e2e/02-authentication.spec.ts +++ b/tests/e2e/02-authentication.spec.ts @@ -27,13 +27,14 @@ test.describe('Authentication', () => { test('should show error with invalid credentials', async ({ page }) => { await page.goto('/auth/login'); - + await page.fill('[name="email"]', 'invalid@email.com'); await page.fill('[name="password"]', 'wrongpassword'); await page.click('button[type="submit"]'); - - // Should show error message - await expect(page.locator('.error, .bg-red-100')).toBeVisible(); + + // Better Auth / form script shows error in #form-response (red styling) + await expect(page.locator('#form-response')).toBeVisible(); + await expect(page.locator('#form-response [class*="red"]')).toBeVisible({ timeout: 5000 }); }); test('should logout successfully', async ({ page }) => { diff --git a/tests/e2e/02b-authentication-api.spec.ts b/tests/e2e/02b-authentication-api.spec.ts index 6112a0fb1..eab387c05 100644 --- a/tests/e2e/02b-authentication-api.spec.ts +++ b/tests/e2e/02b-authentication-api.spec.ts @@ -1,700 +1,356 @@ -import { test, expect } from '@playwright/test'; -import { ADMIN_CREDENTIALS } from './utils/test-helpers'; +import { test, expect } from '@playwright/test' +import { ADMIN_CREDENTIALS } from './utils/test-helpers' -test.describe('Authentication API', () => { +const BETTER_AUTH_SESSION_COOKIE = 'better-auth.session_token' + +test.describe('Authentication API (Better Auth)', () => { const testUser = { + name: 'Test User', email: 'test.api.user@example.com', - password: 'TestPassword123!', - username: 'testapiuser', - firstName: 'Test', - lastName: 'User' - }; + password: 'TestPassword123!' + } - // Seed admin user before all tests test.beforeAll(async ({ request }) => { try { - await request.post('/auth/seed-admin'); - } catch (error) { - // Admin might already exist, ignore errors + await request.post('/auth/seed-admin') + } catch { + // Admin might already exist } - }); - - // Clean up test user after tests - test.afterAll(async ({ request }) => { - // We'll implement cleanup when we have admin API access - // For now, tests are designed to be idempotent - }); + }) - test.describe('POST /auth/register - User Registration', () => { + test.describe('POST /auth/sign-up/email - User Registration', () => { test('should register a new user successfully', async ({ request }) => { const uniqueUser = { ...testUser, email: `test.${Date.now()}@example.com`, - username: `testuser${Date.now()}` - }; - - const response = await request.post('/auth/register', { - data: uniqueUser - }); - - expect(response.status()).toBe(201); - - const data = await response.json(); - expect(data).toHaveProperty('user'); - expect(data).toHaveProperty('token'); - - // Verify user object - expect(data.user).toMatchObject({ - email: uniqueUser.email.toLowerCase(), - username: uniqueUser.username, - firstName: uniqueUser.firstName, - lastName: uniqueUser.lastName, - role: 'viewer' - }); - - // Should have a valid UUID - expect(data.user.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - - // Should have a JWT token - expect(data.token).toBeTruthy(); - expect(data.token.split('.')).toHaveLength(3); // JWT format - }); + name: `Test User ${Date.now()}` + } + + const response = await request.post('/auth/sign-up/email', { + data: { name: uniqueUser.name, email: uniqueUser.email, password: uniqueUser.password } + }) + + expect(response.status()).toBe(200) + const data = await response.json() + expect(data).toHaveProperty('user') + expect(data.user).toHaveProperty('id') + expect(data.user.email).toBe(uniqueUser.email.toLowerCase()) + expect(data.user.name).toBe(uniqueUser.name) + const cookies = response.headers()['set-cookie'] + expect(cookies).toBeTruthy() + expect(cookies).toContain(BETTER_AUTH_SESSION_COOKIE) + }) test('should normalize email to lowercase', async ({ request }) => { const uniqueUser = { ...testUser, email: `TEST.UPPERCASE.${Date.now()}@EXAMPLE.COM`, - username: `testuser${Date.now()}` - }; + name: `Test ${Date.now()}` + } - const response = await request.post('/auth/register', { - data: uniqueUser - }); + const response = await request.post('/auth/sign-up/email', { + data: { name: uniqueUser.name, email: uniqueUser.email, password: uniqueUser.password } + }) - expect(response.status()).toBe(201); - - const data = await response.json(); - expect(data.user.email).toBe(uniqueUser.email.toLowerCase()); - }); + expect(response.status()).toBe(200) + const data = await response.json() + expect(data.user.email).toBe(uniqueUser.email.toLowerCase()) + }) test('should validate required fields', async ({ request }) => { const invalidPayloads = [ - { email: 'test@example.com' }, // Missing other fields - { ...testUser, email: '' }, // Empty email - { ...testUser, email: 'invalid-email' }, // Invalid email format - { ...testUser, password: '123' }, // Too short password - { ...testUser, username: 'ab' }, // Too short username - { ...testUser, firstName: '' }, // Empty first name - { ...testUser, lastName: '' } // Empty last name - ]; + { email: 'test@example.com' }, + { name: 'Test', email: '' }, + { name: 'Test', email: 'invalid-email', password: 'password123' }, + { name: 'Test', email: 'test@example.com', password: '123' } + ] for (const payload of invalidPayloads) { - const response = await request.post('/auth/register', { + const response = await request.post('/auth/sign-up/email', { data: payload - }); - - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - - const data = await response.json(); - expect(data).toHaveProperty('error'); + }) + expect(response.status()).toBeGreaterThanOrEqual(400) + expect(response.status()).toBeLessThan(500) } - }); + }) test('should prevent duplicate email registration', async ({ request }) => { const uniqueUser = { ...testUser, email: `duplicate.test.${Date.now()}@example.com`, - username: `uniqueuser${Date.now()}` - }; - - // First registration should succeed - const firstResponse = await request.post('/auth/register', { - data: uniqueUser - }); - expect(firstResponse.status()).toBe(201); - - // Second registration with same email should fail - const secondResponse = await request.post('/auth/register', { - data: { - ...uniqueUser, - username: `different${Date.now()}` // Different username - } - }); - - expect(secondResponse.status()).toBe(400); - - const data = await secondResponse.json(); - expect(data.error).toContain('already exists'); - }); + name: `Test ${Date.now()}` + } - test('should prevent duplicate username registration', async ({ request }) => { - const uniqueUser = { - ...testUser, - email: `unique.email.${Date.now()}@example.com`, - username: `duplicateusername${Date.now()}` - }; - - // First registration should succeed - const firstResponse = await request.post('/auth/register', { - data: uniqueUser - }); - expect(firstResponse.status()).toBe(201); - - // Second registration with same username should fail - const secondResponse = await request.post('/auth/register', { - data: { - ...uniqueUser, - email: `different.${Date.now()}@example.com` // Different email - } - }); + const firstResponse = await request.post('/auth/sign-up/email', { + data: { name: uniqueUser.name, email: uniqueUser.email, password: uniqueUser.password } + }) + expect(firstResponse.status()).toBe(200) - expect(secondResponse.status()).toBe(400); - - const data = await secondResponse.json(); - expect(data.error).toContain('already exists'); - }); + const secondResponse = await request.post('/auth/sign-up/email', { + data: { name: 'Other', email: uniqueUser.email, password: 'OtherPass123!' } + }) + expect(secondResponse.status()).toBe(400) + const data = await secondResponse.json() + expect(data.message ?? data.error ?? '').toMatch(/already|exist|email/i) + }) - test('should set auth cookie on registration', async ({ request }) => { + test('should set session cookie on registration', async ({ request }) => { const uniqueUser = { ...testUser, email: `cookie.test.${Date.now()}@example.com`, - username: `cookieuser${Date.now()}` - }; - - const response = await request.post('/auth/register', { - data: uniqueUser - }); - - expect(response.status()).toBe(201); - - // Check for auth cookie - const cookies = response.headers()['set-cookie']; - expect(cookies).toBeTruthy(); - expect(cookies).toContain('auth_token'); - expect(cookies).toContain('HttpOnly'); - expect(cookies).toContain('SameSite=Strict'); - }); - - test('should assign viewer role by default', async ({ request }) => { - const uniqueUser = { - ...testUser, - email: `role.test.${Date.now()}@example.com`, - username: `roleuser${Date.now()}` - }; - - const response = await request.post('/auth/register', { - data: uniqueUser - }); - - expect(response.status()).toBe(201); - - const data = await response.json(); - expect(data.user.role).toBe('viewer'); - }); - }); - - test.describe('POST /auth/login - User Login', () => { + name: `Test ${Date.now()}` + } + + const response = await request.post('/auth/sign-up/email', { + data: { name: uniqueUser.name, email: uniqueUser.email, password: uniqueUser.password } + }) + expect(response.status()).toBe(200) + const cookies = response.headers()['set-cookie'] + expect(cookies).toBeTruthy() + expect(cookies).toContain(BETTER_AUTH_SESSION_COOKIE) + expect(cookies).toContain('HttpOnly') + expect(cookies).toContain('SameSite') + }) + }) + + test.describe('POST /auth/sign-in/email - User Login', () => { test('should login successfully with valid credentials', async ({ request }) => { - const response = await request.post('/auth/login', { + const response = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data).toHaveProperty('user'); - expect(data).toHaveProperty('token'); - - // Verify user object + }) + + expect(response.status()).toBe(200) + const data = await response.json() + expect(data).toHaveProperty('user') expect(data.user).toMatchObject({ - email: ADMIN_CREDENTIALS.email, - username: 'admin', - role: 'admin' - }); - - // Should have a JWT token - expect(data.token).toBeTruthy(); - expect(data.token.split('.')).toHaveLength(3); - }); + email: ADMIN_CREDENTIALS.email + }) + const cookies = response.headers()['set-cookie'] + expect(cookies).toBeTruthy() + expect(cookies).toContain(BETTER_AUTH_SESSION_COOKIE) + }) test('should normalize email to lowercase on login', async ({ request }) => { - const response = await request.post('/auth/login', { + const response = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email.toUpperCase(), password: ADMIN_CREDENTIALS.password } - }); + }) - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data.user.email).toBe(ADMIN_CREDENTIALS.email.toLowerCase()); - }); + expect(response.status()).toBe(200) + const data = await response.json() + expect(data.user.email).toBe(ADMIN_CREDENTIALS.email.toLowerCase()) + }) test('should fail with invalid email', async ({ request }) => { - const response = await request.post('/auth/login', { + const response = await request.post('/auth/sign-in/email', { data: { email: 'nonexistent@example.com', password: 'anypassword' } - }); + }) - expect(response.status()).toBe(401); - - const data = await response.json(); - expect(data.error).toContain('Invalid email or password'); - }); + expect(response.status()).toBe(401) + const data = await response.json() + expect(data.message ?? data.error ?? '').toMatch(/invalid|credential|password/i) + }) test('should fail with invalid password', async ({ request }) => { - const response = await request.post('/auth/login', { + const response = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: 'wrongpassword' } - }); - - expect(response.status()).toBe(401); - - const data = await response.json(); - expect(data.error).toContain('Invalid email or password'); - }); - - test('should validate required fields', async ({ request }) => { - const invalidPayloads = [ - { email: '' }, // Missing password - { password: '' }, // Missing email - { email: '', password: '' }, // Both empty - { email: 'invalid-email', password: 'password' } // Invalid email format - ]; + }) - for (const payload of invalidPayloads) { - const response = await request.post('/auth/login', { - data: payload - }); + expect(response.status()).toBe(401) + const data = await response.json() + expect(data.message ?? data.error ?? '').toMatch(/invalid|credential|password/i) + }) - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - } - }); - - test('should set auth cookie on login', async ({ request }) => { - const response = await request.post('/auth/login', { - data: { - email: ADMIN_CREDENTIALS.email, - password: ADMIN_CREDENTIALS.password - } - }); - - expect(response.status()).toBe(200); - - // Check for auth cookie - const cookies = response.headers()['set-cookie']; - expect(cookies).toBeTruthy(); - expect(cookies).toContain('auth_token'); - expect(cookies).toContain('HttpOnly'); - expect(cookies).toContain('SameSite=Strict'); - expect(cookies).toContain('Max-Age=86400'); // 24 hours - }); - - test('should update last login timestamp', async ({ request }) => { - // Login twice and verify the second login has updated timestamp - const firstLogin = await request.post('/auth/login', { + test('should set session cookie on login', async ({ request }) => { + const response = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - expect(firstLogin.status()).toBe(200); - - // Wait a moment - await new Promise(resolve => setTimeout(resolve, 100)); - - const secondLogin = await request.post('/auth/login', { - data: { - email: ADMIN_CREDENTIALS.email, - password: ADMIN_CREDENTIALS.password - } - }); - expect(secondLogin.status()).toBe(200); - }); - }); - - test.describe('POST /auth/logout - User Logout', () => { + }) + + expect(response.status()).toBe(200) + const cookies = response.headers()['set-cookie'] + expect(cookies).toBeTruthy() + expect(cookies).toContain(BETTER_AUTH_SESSION_COOKIE) + expect(cookies).toContain('HttpOnly') + expect(cookies).toContain('SameSite') + }) + }) + + test.describe('POST /auth/sign-out - User Logout', () => { test('should logout successfully', async ({ request }) => { - // First login to get a session - const loginResponse = await request.post('/auth/login', { + const loginResponse = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - expect(loginResponse.status()).toBe(200); + }) + expect(loginResponse.status()).toBe(200) - // Extract auth cookie - const cookies = loginResponse.headers()['set-cookie']; - const authCookie = cookies?.split(';')[0] || ''; + const cookies = loginResponse.headers()['set-cookie'] + const sessionCookie = cookies?.split(';')[0] ?? '' - // Logout with the session - const logoutResponse = await request.post('/auth/logout', { - headers: { - 'Cookie': authCookie - } - }); - - expect(logoutResponse.status()).toBe(200); - - const data = await logoutResponse.json(); - expect(data.message).toContain('Logged out successfully'); - - // Check that auth cookie is cleared - const logoutCookies = logoutResponse.headers()['set-cookie']; - expect(logoutCookies).toContain('auth_token='); - expect(logoutCookies).toContain('Max-Age=0'); - }); + const logoutResponse = await request.post('/auth/sign-out', { + headers: { Cookie: sessionCookie } + }) - test('GET /auth/logout should redirect to login', async ({ request }) => { - const response = await request.get('/auth/logout', { - maxRedirects: 0 // Don't follow redirects - }); - - expect(response.status()).toBe(302); - expect(response.headers()['location']).toContain('/auth/login'); - }); - }); + expect(logoutResponse.status()).toBe(200) + const logoutCookies = logoutResponse.headers()['set-cookie'] + expect(logoutCookies).toBeTruthy() + expect(logoutCookies).toMatch(new RegExp(`${BETTER_AUTH_SESSION_COOKIE.replace('.', '\\.')}=`)) + expect(logoutCookies).toMatch(/Max-Age=0|expires=/i) + }) + }) test.describe('GET /auth/me - Current User', () => { test('should return current user when authenticated', async ({ request }) => { - // Login first - const loginResponse = await request.post('/auth/login', { + const loginResponse = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - expect(loginResponse.status()).toBe(200); + }) + expect(loginResponse.status()).toBe(200) - // Extract auth cookie - const cookies = loginResponse.headers()['set-cookie']; - const authCookie = cookies?.split(';')[0] || ''; + const cookies = loginResponse.headers()['set-cookie'] + const sessionCookie = cookies?.split(';')[0] ?? '' - // Get current user const meResponse = await request.get('/auth/me', { - headers: { - 'Cookie': authCookie - } - }); + headers: { Cookie: sessionCookie } + }) - expect(meResponse.status()).toBe(200); - - const data = await meResponse.json(); - expect(data).toHaveProperty('user'); + expect(meResponse.status()).toBe(200) + const data = await meResponse.json() + expect(data).toHaveProperty('user') expect(data.user).toMatchObject({ email: ADMIN_CREDENTIALS.email, username: 'admin', role: 'admin' - }); - - // Should not expose password hash - expect(data.user).not.toHaveProperty('password_hash'); - expect(data.user).not.toHaveProperty('password'); - }); + }) + expect(data.user).not.toHaveProperty('password_hash') + expect(data.user).not.toHaveProperty('password') + }) test('should return 401 when not authenticated', async ({ request }) => { - const response = await request.get('/auth/me'); - - expect(response.status()).toBe(401); - - const data = await response.json(); - expect(data.error).toContain('Authentication required'); - }); - - test('should return 401 with invalid token', async ({ request }) => { - const response = await request.get('/auth/me', { - headers: { - 'Cookie': 'auth_token=invalid.jwt.token' - } - }); - - expect(response.status()).toBe(401); - }); - }); - - test.describe('POST /auth/refresh - Token Refresh', () => { - test('should refresh token when authenticated', async ({ request }) => { - // Login first - const loginResponse = await request.post('/auth/login', { + const response = await request.get('/auth/me') + expect(response.status()).toBe(401) + const data = await response.json() + expect(data.error).toMatch(/auth|required/i) + }) + }) + + test.describe('POST /auth/refresh', () => { + test('should return message (session managed by Better Auth)', async ({ request }) => { + const loginResponse = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - expect(loginResponse.status()).toBe(200); - - const loginData = await loginResponse.json(); - const originalToken = loginData.token; - - // Extract auth cookie - const cookies = loginResponse.headers()['set-cookie']; - const authCookie = cookies?.split(';')[0] || ''; + }) + expect(loginResponse.status()).toBe(200) + const cookies = loginResponse.headers()['set-cookie'] + const sessionCookie = cookies?.split(';')[0] ?? '' - // Wait a moment to ensure different timestamp - await new Promise(resolve => setTimeout(resolve, 1100)); - - // Refresh token const refreshResponse = await request.post('/auth/refresh', { - headers: { - 'Cookie': authCookie - } - }); - - expect(refreshResponse.status()).toBe(200); - - const refreshData = await refreshResponse.json(); - expect(refreshData).toHaveProperty('token'); - - // Token should be valid JWT format - expect(refreshData.token.split('.')).toHaveLength(3); - - // Should return a token (may be same if called within same second) - expect(refreshData.token).toBeTruthy(); - - // Check for new auth cookie - const refreshCookies = refreshResponse.headers()['set-cookie']; - expect(refreshCookies).toContain('auth_token'); - }); + headers: { Cookie: sessionCookie } + }) - test('should return 401 when not authenticated', async ({ request }) => { - const response = await request.post('/auth/refresh'); + expect(refreshResponse.status()).toBe(200) + const data = await refreshResponse.json() + expect(data.message).toMatch(/Better Auth|session|refresh/i) + }) - expect(response.status()).toBe(401); - - const data = await response.json(); - expect(data.error).toContain('Authentication required'); - }); - }); + test('should return 401 when not authenticated', async ({ request }) => { + const response = await request.post('/auth/refresh') + expect(response.status()).toBe(401) + const data = await response.json() + expect(data.error).toMatch(/auth|required/i) + }) + }) test.describe('Security Tests', () => { - test('should not expose sensitive data in responses', async ({ request }) => { - // Register a new user + test('should not expose sensitive data in registration response', async ({ request }) => { const uniqueUser = { ...testUser, email: `security.test.${Date.now()}@example.com`, - username: `securityuser${Date.now()}` - }; - - const response = await request.post('/auth/register', { - data: uniqueUser - }); - - expect(response.status()).toBe(201); - - const data = await response.json(); - - // Should not expose password or hash - expect(data.user).not.toHaveProperty('password'); - expect(data.user).not.toHaveProperty('password_hash'); - expect(data.user).not.toHaveProperty('passwordHash'); - - // Response should not contain the original password - const responseText = JSON.stringify(data); - expect(responseText).not.toContain(uniqueUser.password); - }); + name: `Test ${Date.now()}` + } + + const response = await request.post('/auth/sign-up/email', { + data: { name: uniqueUser.name, email: uniqueUser.email, password: uniqueUser.password } + }) + expect(response.status()).toBe(200) + const data = await response.json() + expect(data.user).not.toHaveProperty('password') + expect(data.user).not.toHaveProperty('password_hash') + expect(data.user).not.toHaveProperty('passwordHash') + expect(JSON.stringify(data)).not.toContain(uniqueUser.password) + }) test('should handle SQL injection attempts safely', async ({ request }) => { const maliciousPayloads = [ - { - email: "admin@sonicjs.com' OR '1'='1", - password: "anything" - }, - { - email: "admin@sonicjs.com'; DROP TABLE users; --", - password: "anything" - }, - { - email: "admin@sonicjs.com", - password: "' OR '1'='1" - } - ]; + { email: "admin@sonicjs.com' OR '1'='1", password: 'anything' }, + { email: "admin@sonicjs.com'; DROP TABLE users; --", password: 'anything' }, + { email: 'admin@sonicjs.com', password: "' OR '1'='1" } + ] for (const payload of maliciousPayloads) { - const response = await request.post('/auth/login', { - data: payload - }); - - // Should safely reject without exposing SQL errors - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - - const data = await response.json(); - expect(data.error).not.toContain('SQL'); - expect(data.error).not.toContain('syntax'); - } - }); - - test('should handle XSS attempts in registration', async ({ request }) => { - const xssPayload = { - ...testUser, - email: `xss.test.${Date.now()}@example.com`, - username: `xssuser${Date.now()}`, - firstName: '', - lastName: '">' - }; - - const response = await request.post('/auth/register', { - data: xssPayload - }); - - if (response.status() === 201) { - const data = await response.json(); - - // Names should be stored as-is (not executed) - expect(data.user.firstName).toBe(xssPayload.firstName); - expect(data.user.lastName).toBe(xssPayload.lastName); + const response = await request.post('/auth/sign-in/email', { data: payload }) + expect(response.status()).toBeGreaterThanOrEqual(400) + expect(response.status()).toBeLessThan(500) + const data = await response.json() + const err = (data.message ?? data.error ?? '').toString() + expect(err).not.toMatch(/SQL|syntax/i) } - }); + }) - test('should enforce HTTPS-only cookies in production', async ({ request }) => { - const response = await request.post('/auth/login', { + test('should enforce secure cookies', async ({ request }) => { + const response = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - - expect(response.status()).toBe(200); - - const cookies = response.headers()['set-cookie']; - expect(cookies).toContain('HttpOnly'); - expect(cookies).toContain('SameSite=Strict'); - // Note: Secure flag might be disabled in test environment - }); - - test('should rate limit login attempts', async ({ request }) => { - // Make multiple rapid login attempts - const attempts = Array.from({ length: 10 }, () => - request.post('/auth/login', { - data: { - email: 'bruteforce@example.com', - password: 'wrongpassword' - } - }) - ); - - const responses = await Promise.all(attempts); - - // All should either fail with 401 or eventually hit rate limit - responses.forEach(response => { - expect([401, 429]).toContain(response.status()); - }); - }); - }); - - test.describe('Error Handling', () => { - test('should handle malformed JSON gracefully', async ({ request }) => { - const response = await request.post('/auth/login', { - headers: { - 'Content-Type': 'application/json' - }, - data: 'invalid json' - }); - - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - }); - - test('should handle missing content-type', async ({ request }) => { - const response = await request.post('/auth/login', { - headers: { - 'Content-Type': 'text/plain' - }, - data: JSON.stringify({ - email: ADMIN_CREDENTIALS.email, - password: ADMIN_CREDENTIALS.password - }) - }); - - // Should either work or return proper error - expect([200, 400, 415]).toContain(response.status()); - }); - - test('should handle server errors gracefully', async ({ request }) => { - // Test with extremely long values that might cause issues - const response = await request.post('/auth/register', { - data: { - ...testUser, - email: `test.${Date.now()}@example.com`, - username: `user${Date.now()}`, - firstName: 'A'.repeat(10000), // Very long name - lastName: 'B'.repeat(10000) - } - }); - - // Should handle gracefully (either accept or reject with proper error) - if (!response.ok()) { - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(600); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - } - }); - }); + }) + expect(response.status()).toBe(200) + const cookies = response.headers()['set-cookie'] + expect(cookies).toContain('HttpOnly') + expect(cookies).toContain('SameSite') + }) + }) test.describe('Session Management', () => { test('should maintain session across requests', async ({ request }) => { - // Login - const loginResponse = await request.post('/auth/login', { + const loginResponse = await request.post('/auth/sign-in/email', { data: { email: ADMIN_CREDENTIALS.email, password: ADMIN_CREDENTIALS.password } - }); - expect(loginResponse.status()).toBe(200); + }) + expect(loginResponse.status()).toBe(200) + const cookies = loginResponse.headers()['set-cookie'] + const sessionCookie = cookies?.split(';')[0] ?? '' - const cookies = loginResponse.headers()['set-cookie']; - const authCookie = cookies?.split(';')[0] || ''; - - // Make authenticated request const meResponse = await request.get('/auth/me', { - headers: { - 'Cookie': authCookie - } - }); - expect(meResponse.status()).toBe(200); + headers: { Cookie: sessionCookie } + }) + expect(meResponse.status()).toBe(200) - // Make another authenticated request const refreshResponse = await request.post('/auth/refresh', { - headers: { - 'Cookie': authCookie - } - }); - expect(refreshResponse.status()).toBe(200); - }); - - test('should handle concurrent authentication requests', async ({ request }) => { - // Login with same credentials concurrently - const loginPromises = Array.from({ length: 5 }, () => - request.post('/auth/login', { - data: { - email: ADMIN_CREDENTIALS.email, - password: ADMIN_CREDENTIALS.password - } - }) - ); - - const responses = await Promise.all(loginPromises); - - // All should succeed - responses.forEach(response => { - expect(response.status()).toBe(200); - }); - }); - }); -}); \ No newline at end of file + headers: { Cookie: sessionCookie } + }) + expect(refreshResponse.status()).toBe(200) + }) + }) +}) diff --git a/tests/e2e/02c-otp-login.spec.ts b/tests/e2e/02c-otp-login.spec.ts deleted file mode 100644 index 9b0139066..000000000 --- a/tests/e2e/02c-otp-login.spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * OTP Login (Login with Code) E2E Tests - * - * Tests passwordless authentication via email one-time codes. - * - * NOTE: Each test uses a unique email to avoid rate limiting (5 requests/hour per email). - */ - -// Generate unique email for each test to avoid rate limiting -function uniqueEmail(prefix: string): string { - return `${prefix}.${Date.now()}.${Math.random().toString(36).substring(7)}@test.sonicjs.com`; -} - -test.describe('OTP Login (Login with Code)', () => { - - test.describe('POST /auth/otp/request - Request OTP Code', () => { - test('should accept valid email and return success message', async ({ request }) => { - const response = await request.post('/auth/otp/request', { - data: { email: uniqueEmail('otp-valid') } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data).toHaveProperty('message'); - expect(data.message).toContain('verification code'); - expect(data).toHaveProperty('expiresIn'); - expect(data.expiresIn).toBeGreaterThan(0); - }); - - test('should return dev_code in development environment (skipped in CI)', async ({ request }) => { - // Skip this test in CI/production environments - dev_code is only returned in local dev - test.skip(!!process.env.CI, 'dev_code only available in local development'); - - const response = await request.post('/auth/otp/request', { - data: { email: uniqueEmail('otp-devcode') } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - // In development mode, the API may return the code for testing - // Skip if dev_code is not enabled in this environment - if (!data.dev_code) { - test.skip(true, 'dev_code not enabled in this environment - requires DEV_MODE=true'); - return; - } - expect(data.dev_code).toMatch(/^\d{6}$/); // 6-digit code - }); - - test('should normalize email to lowercase', async ({ request }) => { - const email = uniqueEmail('OTP-UPPERCASE'); - const response = await request.post('/auth/otp/request', { - data: { email: email.toUpperCase() } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data).toHaveProperty('message'); - }); - - test('should reject invalid email format', async ({ request }) => { - const response = await request.post('/auth/otp/request', { - data: { email: 'not-an-email' } - }); - - expect(response.status()).toBe(400); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - expect(data.error).toContain('Validation failed'); - }); - - test('should reject empty email', async ({ request }) => { - const response = await request.post('/auth/otp/request', { - data: { email: '' } - }); - - expect(response.status()).toBe(400); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - }); - - test('should reject missing email field', async ({ request }) => { - const response = await request.post('/auth/otp/request', { - data: {} - }); - - expect(response.status()).toBe(400); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - }); - - test('should not reveal if user exists or not (security)', async ({ request }) => { - // Request OTP for non-existing users (both should get same message) - const email1 = uniqueEmail('otp-security1'); - const email2 = uniqueEmail('otp-security2'); - - const response1 = await request.post('/auth/otp/request', { - data: { email: email1 } - }); - - const response2 = await request.post('/auth/otp/request', { - data: { email: email2 } - }); - - expect(response1.status()).toBe(200); - expect(response2.status()).toBe(200); - - const data1 = await response1.json(); - const data2 = await response2.json(); - - // Both should have same generic message (don't reveal if user exists) - expect(data1.message).toBe(data2.message); - }); - - test('should rate limit excessive requests from same email', async ({ request }) => { - // Use same email for all requests to trigger rate limit - const email = uniqueEmail('ratelimit'); - - const promises = Array.from({ length: 10 }, () => - request.post('/auth/otp/request', { - data: { email } - }) - ); - - const responses = await Promise.all(promises); - const statuses = responses.map(r => r.status()); - - // Rate limiting depends on configuration - either we get rate limited (429) - // or all requests succeed (200). Both are valid behaviors depending on config. - const has429 = statuses.includes(429); - const allSuccess = statuses.every(s => s === 200); - - // Test passes if either rate limiting kicks in OR all succeed - // (rate limiting may not be enabled in all environments) - expect(has429 || allSuccess).toBe(true); - }); - }); - - test.describe('POST /auth/otp/verify - Verify OTP Code', () => { - test('should reject invalid OTP code format', async ({ request }) => { - const verifyResponse = await request.post('/auth/otp/verify', { - data: { - email: uniqueEmail('verify-invalid'), - code: '000000' // Valid format but wrong code - } - }); - - // Should return 401 (no valid OTP exists for this email) - expect(verifyResponse.status()).toBe(401); - - const verifyData = await verifyResponse.json(); - expect(verifyData).toHaveProperty('error'); - }); - - test('should validate email format', async ({ request }) => { - const verifyResponse = await request.post('/auth/otp/verify', { - data: { - email: 'not-an-email', - code: '123456' - } - }); - - expect(verifyResponse.status()).toBe(400); - - const verifyData = await verifyResponse.json(); - expect(verifyData).toHaveProperty('error'); - expect(verifyData.error).toContain('Validation failed'); - }); - - test('should validate code format (min 4, max 8 chars)', async ({ request }) => { - const email = uniqueEmail('code-format'); - - // Too short - const shortCodeResponse = await request.post('/auth/otp/verify', { - data: { - email, - code: '123' // 3 chars - too short - } - }); - expect(shortCodeResponse.status()).toBe(400); - - // Too long - const longCodeResponse = await request.post('/auth/otp/verify', { - data: { - email, - code: '123456789' // 9 chars - too long - } - }); - expect(longCodeResponse.status()).toBe(400); - }); - - test('should reject verification for non-existent OTP', async ({ request }) => { - const verifyResponse = await request.post('/auth/otp/verify', { - data: { - email: uniqueEmail('nonexistent'), - code: '123456' - } - }); - - expect(verifyResponse.status()).toBe(401); - }); - }); - - test.describe('POST /auth/otp/resend - Resend OTP Code', () => { - test('should handle resend request', async ({ request }) => { - const response = await request.post('/auth/otp/resend', { - data: { email: uniqueEmail('resend-test') } - }); - - // Resend should return 200 or may not be implemented (404) - // Accept either as valid - the endpoint exists but behavior varies - expect([200, 404]).toContain(response.status()); - - if (response.status() === 200) { - const data = await response.json(); - expect(data).toHaveProperty('message'); - } - }); - - test('should reject invalid email format on resend', async ({ request }) => { - const response = await request.post('/auth/otp/resend', { - data: { email: 'not-an-email' } - }); - - // Should be 400 (validation) or 404 (not implemented) - expect([400, 404]).toContain(response.status()); - }); - }); - - test.describe('Security Tests', () => { - test('should handle SQL injection attempts safely', async ({ request }) => { - const maliciousPayloads = [ - { email: "test' OR '1'='1", code: "123456" }, - { email: "test'; DROP TABLE otp_codes; --", code: "123456" }, - { email: "test@example.com", code: "' OR '1'='1" } - ]; - - for (const payload of maliciousPayloads) { - const response = await request.post('/auth/otp/verify', { - data: payload - }); - - // Should safely reject without exposing SQL errors - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - - const data = await response.json(); - if (data.error) { - expect(data.error).not.toContain('SQL'); - expect(data.error).not.toContain('syntax'); - } - } - }); - }); -}); diff --git a/tests/e2e/02d-magic-link-auth.spec.ts b/tests/e2e/02d-magic-link-auth.spec.ts deleted file mode 100644 index b3ea00255..000000000 --- a/tests/e2e/02d-magic-link-auth.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Magic Link Authentication E2E Tests - * - * Tests passwordless authentication via email magic links. - * - * NOTE: Each test uses a unique email to avoid rate limiting (5 requests/hour per email). - */ - -// Generate unique email for each test to avoid rate limiting -function uniqueEmail(prefix: string): string { - return `${prefix}.${Date.now()}.${Math.random().toString(36).substring(7)}@test.sonicjs.com`; -} - -test.describe('Magic Link Authentication', () => { - - test.describe('POST /auth/magic-link/request - Request Magic Link', () => { - test('should accept valid email and return success message', async ({ request }) => { - const response = await request.post('/auth/magic-link/request', { - data: { email: uniqueEmail('ml-valid') } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data).toHaveProperty('message'); - expect(data.message).toContain('magic link'); - }); - - test('should return dev_link in development environment (skipped in CI)', async ({ request }) => { - // Skip this test in CI/production environments - dev_link is only returned in local dev - test.skip(!!process.env.CI, 'dev_link only available in local development'); - - const response = await request.post('/auth/magic-link/request', { - data: { email: uniqueEmail('ml-devlink') } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - // In development mode, the API may return the link for testing - // Skip if dev_link is not enabled in this environment - if (!data.dev_link) { - test.skip(true, 'dev_link not enabled in this environment - requires DEV_MODE=true'); - return; - } - expect(data.dev_link).toContain('/auth/magic-link/verify?token='); - }); - - test('should normalize email to lowercase', async ({ request }) => { - const email = uniqueEmail('ML-UPPERCASE'); - const response = await request.post('/auth/magic-link/request', { - data: { email: email.toUpperCase() } - }); - - expect(response.status()).toBe(200); - - const data = await response.json(); - expect(data).toHaveProperty('message'); - }); - - test('should reject invalid email format', async ({ request }) => { - const response = await request.post('/auth/magic-link/request', { - data: { email: 'not-an-email' } - }); - - expect(response.status()).toBe(400); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - expect(data.error).toContain('Validation failed'); - }); - - test('should reject empty email', async ({ request }) => { - const response = await request.post('/auth/magic-link/request', { - data: { email: '' } - }); - - expect(response.status()).toBe(400); - - const data = await response.json(); - expect(data).toHaveProperty('error'); - }); - - test('should not reveal if user exists or not (security)', async ({ request }) => { - // Request links for non-existing users (both should get same message) - const email1 = uniqueEmail('ml-security1'); - const email2 = uniqueEmail('ml-security2'); - - const response1 = await request.post('/auth/magic-link/request', { - data: { email: email1 } - }); - - const response2 = await request.post('/auth/magic-link/request', { - data: { email: email2 } - }); - - expect(response1.status()).toBe(200); - expect(response2.status()).toBe(200); - - const data1 = await response1.json(); - const data2 = await response2.json(); - - // Both should have same generic message (don't reveal if user exists) - expect(data1.message).toBe(data2.message); - }); - - test('should rate limit excessive requests from same email', async ({ request }) => { - // Use same email for all requests to trigger rate limit - const email = uniqueEmail('ml-ratelimit'); - - const promises = Array.from({ length: 10 }, () => - request.post('/auth/magic-link/request', { - data: { email } - }) - ); - - const responses = await Promise.all(promises); - const statuses = responses.map(r => r.status()); - - // Rate limiting depends on configuration - either we get rate limited (429) - // or all requests succeed (200). Both are valid behaviors depending on config. - const has429 = statuses.includes(429); - const allSuccess = statuses.every(s => s === 200); - - // Test passes if either rate limiting kicks in OR all succeed - // (rate limiting may not be enabled in all environments) - expect(has429 || allSuccess).toBe(true); - }); - }); - - test.describe('GET /auth/magic-link/verify - Verify Magic Link', () => { - test('should redirect to login with error for missing token', async ({ request }) => { - const verifyResponse = await request.get('/auth/magic-link/verify', { - maxRedirects: 0 - }); - - expect(verifyResponse.status()).toBe(302); - expect(verifyResponse.headers()['location']).toContain('/auth/login'); - expect(verifyResponse.headers()['location']).toContain('error='); - }); - - test('should redirect to login with error for invalid token', async ({ request }) => { - const verifyResponse = await request.get('/auth/magic-link/verify?token=invalid-token', { - maxRedirects: 0 - }); - - expect(verifyResponse.status()).toBe(302); - expect(verifyResponse.headers()['location']).toContain('/auth/login'); - expect(verifyResponse.headers()['location']).toContain('error='); - }); - }); - - test.describe('Security Tests', () => { - test('should handle SQL injection attempts safely', async ({ request }) => { - const maliciousTokens = [ - "' OR '1'='1", - "'; DROP TABLE magic_links; --", - "token' OR 'x'='x" - ]; - - for (const token of maliciousTokens) { - const response = await request.get(`/auth/magic-link/verify?token=${encodeURIComponent(token)}`, { - maxRedirects: 0 - }); - - // Should safely redirect without exposing SQL errors - expect(response.status()).toBe(302); - expect(response.headers()['location']).toContain('/auth/login'); - expect(response.headers()['location']).not.toContain('SQL'); - } - }); - - test('should handle very long tokens', async ({ request }) => { - const longToken = 'a'.repeat(1000); - - const response = await request.get(`/auth/magic-link/verify?token=${longToken}`, { - maxRedirects: 0 - }); - - // Should handle gracefully - expect(response.status()).toBe(302); - expect(response.headers()['location']).toContain('/auth/login'); - }); - - test('should handle special characters in token', async ({ request }) => { - const specialTokens = [ - '', - '../../../etc/passwd' - ]; - - for (const token of specialTokens) { - const response = await request.get(`/auth/magic-link/verify?token=${encodeURIComponent(token)}`, { - maxRedirects: 0 - }); - - // Should handle gracefully without errors - expect(response.status()).toBe(302); - } - }); - }); - - test.describe('Error Handling', () => { - test('should handle malformed JSON gracefully', async ({ request }) => { - const response = await request.post('/auth/magic-link/request', { - headers: { - 'Content-Type': 'application/json' - }, - data: 'invalid json' - }); - - expect(response.status()).toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - }); - }); -}); diff --git a/tests/e2e/44-otp-login-admin.spec.ts b/tests/e2e/44-otp-login-admin.spec.ts deleted file mode 100644 index 65d23bbb8..000000000 --- a/tests/e2e/44-otp-login-admin.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { test, expect } from '@playwright/test' -import { loginAsAdmin } from './utils/test-helpers' - -// TODO: These tests pass locally but fail in CI due to D1 migration timing issues -// Skipping until the CI D1 propagation issue is resolved -test.describe.skip('OTP Login Plugin Admin Page', () => { - test.beforeEach(async ({ page }) => { - await loginAsAdmin(page) - }) - - test.describe('Plugin Settings Page', () => { - test('should load the OTP Login page at /admin/plugins/otp-login', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content to load - await page.waitForSelector('h1, h2, h3', { state: 'visible', timeout: 10000 }) - - // Should show the plugin settings page with OTP Login content - // The page title in the header should contain OTP Login - await expect(page.locator('h1, h2').first()).toBeVisible() - }) - - test('should display test email form', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check for Test OTP Email section - await expect(page.locator('text=Test OTP Email').first()).toBeVisible() - - // Check email input field - await expect(page.locator('input#testEmail')).toBeVisible() - - // Check send button - await expect(page.locator('button#sendTestBtn')).toBeVisible() - await expect(page.locator('#sendBtnText')).toContainText('Send Test Code') - }) - - test('should display email preview section', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check for Email Preview section - await expect(page.locator('text=Email Preview').first()).toBeVisible() - - // Check preview content - await expect(page.locator('text=Your Login Code').first()).toBeVisible() - await expect(page.locator('text=Enter this code to sign in').first()).toBeVisible() - await expect(page.locator('text=Security Notice').first()).toBeVisible() - }) - - test('should display code settings form', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check for Code Settings section - await expect(page.locator('text=Code Settings').first()).toBeVisible() - - // Check form fields (using setting_ prefix) - await expect(page.locator('input#setting_codeLength')).toBeVisible() - await expect(page.locator('input#setting_codeExpiryMinutes')).toBeVisible() - await expect(page.locator('input#setting_maxAttempts')).toBeVisible() - await expect(page.locator('input#setting_rateLimitPerHour')).toBeVisible() - await expect(page.locator('input#setting_allowNewUserRegistration')).toBeVisible() - }) - - test('should display quick links to related settings', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check quick links (use .first() since links may appear multiple times) - await expect(page.locator('a[href="/admin/plugins/email"]').first()).toBeVisible() - await expect(page.locator('a[href="/admin/settings/general"]').first()).toBeVisible() - }) - - test('should show email configuration status', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Should show either configured or not configured status - // (one of these should be visible depending on email plugin config) - const configuredStatus = page.locator('text=Email configured') - const notConfiguredStatus = page.locator('text=Email not configured') - - const isConfigured = await configuredStatus.isVisible() - const isNotConfigured = await notConfiguredStatus.isVisible() - - // One of them must be visible - expect(isConfigured || isNotConfigured).toBe(true) - }) - - test('should display features list', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check features section (use .first() since text may appear multiple times) - await expect(page.locator('text=Passwordless authentication').first()).toBeVisible() - await expect(page.locator('text=Rate limiting protection').first()).toBeVisible() - await expect(page.locator('text=Brute force prevention').first()).toBeVisible() - }) - - test('should have save settings button', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for page content - await page.waitForSelector('h3', { state: 'visible', timeout: 10000 }) - - // Check save button - await expect(page.locator('button#save-button')).toBeVisible() - }) - - test('should send test code and show result', async ({ page }) => { - await page.goto('/admin/plugins/otp-login') - await page.waitForLoadState('networkidle') - - // Wait for form to be visible - await page.waitForSelector('input#testEmail', { state: 'visible', timeout: 10000 }) - - // Fill in email - await page.fill('input#testEmail', 'test@example.com') - - // Click send button - await page.click('button#sendTestBtn') - - // Should show loading state - await expect(page.locator('#sendBtnText')).toContainText('Sending') - - // Wait for result (either success or error) - await page.waitForSelector('#testResult:not(.hidden)', { timeout: 10000 }) - - // Should show some result - const resultBox = page.locator('#testResult') - await expect(resultBox).toBeVisible() - }) - }) -}) diff --git a/tests/e2e/utils/test-helpers.ts b/tests/e2e/utils/test-helpers.ts index 85e092f13..d3b32c0aa 100644 --- a/tests/e2e/utils/test-helpers.ts +++ b/tests/e2e/utils/test-helpers.ts @@ -315,57 +315,31 @@ export async function ensureTestCollectionExists(page: Page) { } /** - * Login as admin user + * Login as admin user (form submits to Better Auth /auth/sign-in/email, then redirects to /admin) */ export async function loginAsAdmin(page: Page) { - // Ensure admin user exists first await ensureAdminUserExists(page); - + await page.goto('/auth/login'); await page.fill('[name="email"]', ADMIN_CREDENTIALS.email); await page.fill('[name="password"]', ADMIN_CREDENTIALS.password); await page.click('button[type="submit"]'); - - // Wait for HTMX response and success message with longer timeout for CI - // CI environments can be slow, especially with Workers cold starts - try { - await expect(page.locator('#form-response .bg-green-100')).toBeVisible({ timeout: 10000 }); - } catch (error) { - // If success message doesn't appear, check if we're already redirected to admin - const currentUrl = page.url(); - if (currentUrl.includes('/admin')) { - console.log('Login succeeded (already on admin page despite missing success message)'); - } else { - // Try one more time - sometimes Workers need a moment - console.log('Retrying login form submission...'); - await page.click('button[type="submit"]'); - await expect(page.locator('#form-response .bg-green-100')).toBeVisible({ timeout: 10000 }); - } - } - // Wait for JavaScript redirect to admin dashboard (up to 20 seconds for CI) - // The app redirects /admin to /admin/dashboard, so we accept any /admin/* URL + // Login form uses fetch to /auth/sign-in/email; on success it does window.location = '/admin/dashboard' try { await page.waitForURL(/\/admin/, { timeout: 20000 }); await page.waitForLoadState('networkidle', { timeout: 15000 }); - } catch (error) { - // If redirect doesn't happen automatically, try navigating manually + } catch { console.log('Auto-redirect failed, navigating manually to /admin'); await page.goto('/admin'); await page.waitForLoadState('networkidle', { timeout: 15000 }); } - // Simply verify we're on an admin page - accept /admin or /admin/* - // The app redirects /admin -> /admin/dashboard await expect(page).toHaveURL(/\/admin/); - // Ensure workflow tables exist for workflow tests (after login) await ensureWorkflowTablesExist(page); - - // Ensure workflow plugin is active for workflow-related tests await ensureWorkflowPluginActive(page); - // Navigate back to admin dashboard after plugin setup await page.goto('/admin'); await page.waitForLoadState('networkidle', { timeout: 15000 }); } @@ -497,14 +471,17 @@ export async function waitForHTMX(page: Page) { } } +/** Better Auth default session cookie name */ +const BETTER_AUTH_SESSION_COOKIE = 'better-auth.session_token'; + /** - * Check if user is authenticated by checking for auth cookie + * Check if user is authenticated by checking for Better Auth session cookie */ export async function isAuthenticated(page: Page): Promise { try { const cookies = await page.context().cookies(); - const authCookie = cookies.find(c => c.name === 'auth_token'); - return !!authCookie; + const sessionCookie = cookies.find(c => c.name === BETTER_AUTH_SESSION_COOKIE); + return !!sessionCookie; } catch { return false; } diff --git a/www/src/app/authentication/page.mdx b/www/src/app/authentication/page.mdx index 46d32f591..a73c89af8 100644 --- a/www/src/app/authentication/page.mdx +++ b/www/src/app/authentication/page.mdx @@ -1,53 +1,51 @@ export const metadata = { title: 'Authentication - SonicJS', description: - 'Secure your SonicJS application with JWT authentication, role-based access control, and user management.', + 'Secure your SonicJS application with Better Auth: session-based authentication, RBAC, and optional magic link, OTP, and OAuth.', } export const sections = [ { title: 'Overview', id: 'overview' }, - { title: 'JWT Authentication', id: 'jwt-authentication' }, - { title: 'Passwordless Authentication', id: 'passwordless-authentication' }, + { title: 'Session Authentication', id: 'session-authentication' }, + { title: 'Magic Link & Email OTP', id: 'magic-link-otp' }, { title: 'User Management', id: 'user-management' }, { title: 'RBAC', id: 'rbac' }, ] # Authentication -Secure your SonicJS application with JWT authentication and role-based access control. {{ className: 'lead' }} +Secure your SonicJS application with Better Auth: session-based authentication and role-based access control. {{ className: 'lead' }} ## Overview -SonicJS uses JWT (JSON Web Tokens) for authentication with: +SonicJS uses **Better Auth** for sign-in, sign-up, and sessions: -- **24-hour token expiration** -- **HTTP-only cookie storage** for web clients -- **Bearer token support** for API clients -- **KV-based token caching** (5-minute TTL) -- **Role-based access control** (RBAC) -- **Permission system** for fine-grained access +- **Session cookie** — HTTP-only `better-auth.session_token` (no JWT in app code) +- **Email/password** — `POST /auth/sign-in/email`, `POST /auth/sign-up/email` +- **RBAC** — First user gets `admin`, others get `viewer` by default +- **Extensible** — Add Google, magic link, email OTP, 2FA via `auth.extendBetterAuth` + - + ```bash {{title:'cURL'}} -curl -X POST http://localhost:8787/auth/login \ +curl -X POST http://localhost:8787/auth/sign-in/email \ -H "Content-Type: application/json" \ -d '{ "email": "admin@sonicjs.com", "password": "sonicjs!" - }' + }' \ + -c cookies.txt ``` ```javascript -const response = await fetch('http://localhost:8787/auth/login', { +const response = await fetch('http://localhost:8787/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@sonicjs.com', password: 'sonicjs!' - }) + }), + credentials: 'include' }); - -const { user, token } = await response.json(); ``` -**Response (200 OK):** - -```json -{ - "user": { - "id": "admin-user-id", - "email": "admin@sonicjs.com", - "username": "admin", - "firstName": "Admin", - "lastName": "User", - "role": "admin" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` +On success, Better Auth sets a session cookie. Use `credentials: 'include'` in browsers so the cookie is sent on later requests. -### Using the Token +### Using the Session -Include the token in the Authorization header for authenticated requests: +Include the session cookie on authenticated requests. In browsers, send credentials: ```bash {{title:'cURL'}} -curl http://localhost:8787/admin/content \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +curl http://localhost:8787/auth/me \ + -b cookies.txt ``` ```javascript -const response = await fetch('http://localhost:8787/admin/content', { - headers: { - 'Authorization': `Bearer ${token}` - } +const response = await fetch('http://localhost:8787/auth/me', { + credentials: 'include' }); ``` -For browser-based applications, the token is automatically stored as an HTTP-only cookie named `auth_token`. - ---- - -## Passwordless Authentication +### Registration -SonicJS offers multiple passwordless authentication methods through plugins. These plugins are **inactive by default** and need to be enabled in the admin panel. + -### OTP Login Plugin +Send `email`, `password`, and `name`. Optional fields (e.g. `username`, `firstName`, `lastName`) depend on your Better Auth user config. First registered user gets the `admin` role. -The **OTP (One-Time Password) Login Plugin** provides email-based authentication with temporary codes. - -**Features:** -- Configurable OTP code length (4-8 digits) -- Code expiration (5-60 minutes, default: 10 minutes) -- Rate limiting (default: 5 codes per hour) -- Max verification attempts (default: 3) -- Optional new user registration support - -**Installation:** - -1. Navigate to **Admin** → **Plugins** -2. Find **OTP Login** plugin -3. Click **Activate** -4. Configure settings: - - **Code Length**: 4-8 digits (default: 6) - - **Code Expiration**: 5-60 minutes (default: 10) - - **Max Attempts**: 3-10 (default: 3) - - **Rate Limit**: Codes per hour (default: 5) - - **Allow Registration**: Enable if you want new users to register via OTP - -**API Usage:** - - - -```bash {{title:'Request OTP Code'}} -# Step 1: Request OTP code -curl -X POST http://localhost:8787/auth/otp/request \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com" - }' - -# Response: { "message": "OTP code sent to email" } -``` - -```bash {{title:'Verify OTP Code'}} -# Step 2: Verify OTP code -curl -X POST http://localhost:8787/auth/otp/verify \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "code": "123456" - }' - -# Response: { "user": {...}, "token": "eyJhbGc..." } -``` - - - -**Database Schema:** - -The OTP plugin creates a new table `otp_codes`: - -```sql -CREATE TABLE otp_codes ( - id TEXT PRIMARY KEY, - user_email TEXT NOT NULL, - code TEXT NOT NULL, - expires_at INTEGER NOT NULL, - attempts INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (unixepoch()) -); -``` - -### Magic Link Authentication Plugin - -The **Magic Link Plugin** enables passwordless login via email links. - -**Features:** -- One-click email authentication -- No password required -- Secure token-based links -- Optional user registration - -**Installation:** - -1. Navigate to **Admin** → **Plugins** -2. Find **Magic Link Auth** plugin -3. Click **Activate** -4. Configure email settings (requires Email Plugin) - -**API Usage:** +--- - +## Magic Link & Email OTP -```bash {{title:'Request Magic Link'}} -# Step 1: Request magic link -curl -X POST http://localhost:8787/auth/magic-link/request \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com" - }' +Passwordless login (magic link or email OTP) is added via **Better Auth plugins** in your app config, not as separate SonicJS plugins. -# Response: { "message": "Magic link sent to email" } -``` +### Magic Link -```bash {{title:'Verify Magic Link'}} -# Step 2: User clicks link in email -# GET /auth/magic-link/verify?token=abc123... +1. Add the `magicLink` plugin in `auth.extendBetterAuth` and implement `sendMagicLink` (e.g. with your email service). +2. Use the Better Auth client with `magicLinkClient` and call `signIn.magicLink({ email, callbackURL })`. -# Automatically redirects to admin with auth cookie set -``` +See [Better Auth – Magic Link](https://www.better-auth.com/docs/plugins/magic-link) and [docs/authentication.md](https://github.com/sonicjs/sonicjs/blob/main/docs/authentication.md#magic-link-better-auth-plugin) for a full SonicJS example. - +### Email OTP -### Choosing an Authentication Method +1. Add the `emailOtp` plugin in `auth.extendBetterAuth` and implement `sendVerificationOTP({ email, otp, type })`. +2. Use the Better Auth client with `emailOtpClient` and call `authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' })`, then verify the OTP. -| Method | Best For | Security | User Experience | -|--------|----------|----------|-----------------| -| **Email + Password** | Traditional apps, maximum control | ⭐⭐⭐⭐ | Familiar, requires password management | -| **OTP Login** | Mobile apps, high-security environments | ⭐⭐⭐⭐⭐ | Easy, no password needed, limited time | -| **Magic Link** | Simplified onboarding, newsletters | ⭐⭐⭐⭐ | Seamless, requires email access | +See [Better Auth – Email OTP](https://www.better-auth.com/docs/plugins/email-otp) and [docs/authentication.md](https://github.com/sonicjs/sonicjs/blob/main/docs/authentication.md#email-otp-better-auth-plugin) for a full SonicJS example. -**Email Plugin Required:** Both OTP and Magic Link authentication require the Email Plugin to be configured with a valid email service (e.g., Resend, SendGrid). +**Config, not plugins:** Magic link and email OTP are enabled by extending Better Auth in `SonicJSConfig.auth.extendBetterAuth`, not by activating plugins in the admin panel. --- ## User Management -### User Registration - - - - +### Pages -```bash {{title:'cURL'}} -curl -X POST http://localhost:8787/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "securepassword123", - "username": "newuser", - "firstName": "John", - "lastName": "Doe" - }' -``` - -```javascript -const response = await fetch('http://localhost:8787/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'securepassword123', - username: 'newuser', - firstName: 'John', - lastName: 'Doe' - }) -}); +- **GET** `/auth/login` — Login form (submits to `POST /auth/sign-in/email`) +- **GET** `/auth/register` — Registration form (submits to `POST /auth/sign-up/email`) +- **GET** `/auth/me` — Current user (requires session cookie) +- **GET** or **POST** `/auth/logout` — Sign out (calls Better Auth sign-out, redirects to login) -const { user, token } = await response.json(); -``` - - +Required env: `BETTER_AUTH_SECRET` (min 32 chars), `BETTER_AUTH_URL`. See [deployment](/deployment). --- @@ -298,7 +155,7 @@ const { user, token } = await response.json(); ### User Roles -SonicJS supports four built-in roles: +SonicJS supports built-in roles: @@ -307,9 +164,6 @@ SonicJS supports four built-in roles: Content management. Can create, edit, publish, and delete content. - - Content creation. Can create and edit own content, but not publish. - Read-only access. Can view content but not modify. diff --git a/www/src/app/plugins/page.mdx b/www/src/app/plugins/page.mdx index 58c44f755..037b45b72 100644 --- a/www/src/app/plugins/page.mdx +++ b/www/src/app/plugins/page.mdx @@ -6,7 +6,7 @@ export const metadata = { export const sections = [ { title: 'Overview', id: 'overview' }, { title: 'Core Plugins', id: 'core-plugins' }, - { title: 'Authentication Plugins', id: 'authentication-plugins' }, + { title: 'Authentication (Better Auth)', id: 'authentication-better-auth' }, { title: 'Editor Plugins', id: 'editor-plugins' }, { title: 'Utility Plugins', id: 'utility-plugins' }, { title: 'Plugin Management', id: 'plugin-management' }, @@ -25,7 +25,7 @@ SonicJS plugins provide modular functionality that can be enabled or disabled wi **Plugin Types:** - **Core Plugins** - Built-in plugins installed with SonicJS -- **Authentication Plugins** - Passwordless login, OTP, magic links +- **Authentication** - Magic link and email OTP via Better Auth config - **Editor Plugins** - Rich text and markdown editors - **Utility Plugins** - Email, workflows, database tools @@ -103,8 +103,7 @@ Send transactional emails via Resend, SendGrid, or other email services. 3. Test with "Send Test Email" button **Required for:** -- OTP Login Plugin -- Magic Link Authentication +- Better Auth magic link / email OTP (when configured) - Password reset emails --- @@ -143,59 +142,14 @@ This plugin is **automatically disabled in production** for security. --- -## Authentication Plugins +## Authentication (Better Auth) -Extend authentication with passwordless login methods. +Passwordless login (magic link, email OTP) is added via **Better Auth plugins** in your app config, not as admin-activated plugins. -### OTP Login Plugin +- **Magic link** — Add the `magicLink` plugin in `SonicJSConfig.auth.extendBetterAuth` and implement `sendMagicLink`. See [Authentication - Magic Link](/authentication#magic-link--email-otp). +- **Email OTP** — Add the `emailOtp` plugin in `auth.extendBetterAuth` and implement `sendVerificationOTP`. See [Authentication - Email OTP](/authentication#magic-link--email-otp). -**Status:** Inactive by default -**Version:** 1.0.0-beta.1 - -One-time password authentication via email. - -**Features:** -- Configurable OTP code length (4-8 digits) -- Code expiration (5-60 minutes, default: 10) -- Rate limiting (default: 5 codes per hour) -- Max verification attempts (default: 3) -- Optional new user registration - -**Database Migration:** `021_add_otp_login.sql` - -**Installation:** - -1. Navigate to **Admin** → **Plugins** -2. Find **OTP Login** plugin -3. Click **Activate** -4. Configure settings -5. Ensure **Email Plugin** is configured - -**Usage:** See [Authentication - OTP Login](/authentication#passwordless-authentication) - ---- - -### Magic Link Authentication - -**Status:** Inactive by default -**Version:** 1.0.0-beta.1 - -Passwordless login via email magic links. - -**Features:** -- One-click email authentication -- No password required -- Secure token-based links -- Optional user registration - -**Installation:** - -1. Navigate to **Admin** → **Plugins** -2. Find **Magic Link Auth** plugin -3. Click **Activate** -4. Ensure **Email Plugin** is configured - -**Usage:** See [Authentication - Magic Link](/authentication#passwordless-authentication) +Configure your Email Plugin so Better Auth can send magic link and OTP emails when those methods are enabled. --- diff --git a/www/src/app/security/page.mdx b/www/src/app/security/page.mdx index 02f3eec68..31718850d 100644 --- a/www/src/app/security/page.mdx +++ b/www/src/app/security/page.mdx @@ -31,8 +31,8 @@ SonicJS is built with security in mind, implementing industry best practices out features={[ { icon: '🔐', - title: 'JWT Authentication', - description: 'Secure token-based authentication with automatic expiration', + title: 'Session Authentication', + description: 'Better Auth session-based auth with HTTP-only cookies', }, { icon: '🛡️', @@ -57,87 +57,37 @@ SonicJS is built with security in mind, implementing industry best practices out ## Authentication -### JWT Token Security +### Session Security (Better Auth) -SonicJS uses JWT (JSON Web Tokens) for authentication with these security features: +SonicJS uses **Better Auth** for sign-in and sessions with these security features: -- **HS256 Algorithm**: HMAC-SHA256 for token signing -- **24-hour Expiration**: Tokens automatically expire -- **Secure Storage**: Tokens stored in HttpOnly cookies - - - -```typescript -interface JWTPayload { - userId: string // User identifier - email: string // User email - role: string // User role for authorization - exp: number // Expiration timestamp - iat: number // Issued at timestamp -} -``` - - +- **Session cookie** — HTTP-only `better-auth.session_token` (no client-side token handling) +- **Configurable expiration** — Session lifetime and refresh via Better Auth options +- **Secure storage** — Sessions stored in database and/or cookie by Better Auth ### Cookie Security -Authentication cookies are configured with maximum security: - - - -```typescript -// Default cookie settings (set automatically) -{ - httpOnly: true, // Prevents JavaScript access - secure: true, // HTTPS only - sameSite: 'Strict', // Prevents CSRF - maxAge: 86400, // 24 hours - path: '/' -} -``` - - +Better Auth sets the session cookie with secure defaults (httpOnly, sameSite, secure in production). Cookie options are configurable in your Better Auth config. ### Password Security -Passwords are hashed using the Web Crypto API: - - - -```typescript -// SonicJS hashes passwords automatically -// Using SHA-256 with salt - -// Registration - password is hashed before storage -const hashedPassword = await hashPassword(plainPassword) - -// Login - compare hashed values -const isValid = await verifyPassword(plainPassword, storedHash) -``` - - +Better Auth hashes and verifies passwords for sign-in and sign-up. Do not use custom password hashing for new features. -Always use a unique, strong JWT secret in production. Never use the default secret. +Always set a strong **BETTER_AUTH_SECRET** (min 32 characters) in production. See [Deployment](/deployment). ### Multi-Factor Authentication -For enhanced security, use the OTP Login plugin: +For 2FA or passwordless login, add Better Auth plugins in `auth.extendBetterAuth`: - + ```typescript -// OTP provides additional verification -// 1. User enters email -// 2. Receives one-time code -// 3. Enters code to complete login - -// OTP security features: -// - Rate limiting per hour -// - Maximum attempt limits -// - Short expiration time -// - IP and user agent tracking +// In SonicJSConfig.auth.extendBetterAuth: +// 1. Add magicLink or emailOtp plugin from better-auth/plugins +// 2. Implement sendMagicLink or sendVerificationOTP (e.g. with Email Plugin) +// See Authentication docs for full examples. ``` diff --git a/www/src/app/troubleshooting/page.mdx b/www/src/app/troubleshooting/page.mdx index c1fd1ffa8..cbc412504 100644 --- a/www/src/app/troubleshooting/page.mdx +++ b/www/src/app/troubleshooting/page.mdx @@ -40,56 +40,41 @@ When debugging issues, check the console logs first. SonicJS logs detailed error **Error Message:** `"Authentication required"` or `"Invalid or expired token"` **Causes:** -- Missing JWT token in request -- Token not in Authorization header or auth_token cookie -- Malformed Bearer token format +- Session cookie not sent (e.g. missing `credentials: 'include'` or wrong origin) +- Session expired or invalid +- `BETTER_AUTH_SECRET` or `BETTER_AUTH_URL` mismatch **Solutions:** - + ```typescript -// Correct format - Authorization header +// Browser: send credentials so session cookie is included fetch('/api/content', { - headers: { - 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - } + credentials: 'include' }) -// Or use cookies (set by login endpoint) -fetch('/api/content', { - credentials: 'include' // Include cookies in request -}) +// Ensure BETTER_AUTH_URL matches the app URL (same origin for cookies) ``` -### 401 - Expired Token +### 401 - Expired Session **Error Message:** `"Your session has expired, please login again"` **Causes:** -- Token's `exp` timestamp has passed -- Default token lifetime is 24 hours +- Session has expired (lifetime configured in Better Auth) +- User signed out elsewhere **Solutions:** - + ```typescript -// Call refresh endpoint before token expires -const response = await fetch('/auth/refresh', { - method: 'POST', - credentials: 'include' -}) - -if (response.ok) { - // New token automatically set in cookie - console.log('Token refreshed') -} else { - // Redirect to login - window.location.href = '/admin/login' -} +// Session refresh is handled by Better Auth; if session is gone, sign in again +// Redirect to login +window.location.href = '/auth/login' ```