Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie sessions, validated against the database on every request so revocation, bans and role changes take effect immediately. Backend: - betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and the admin plugin; auth-schema.ts maps Better Auth's models onto the existing `users` table so user IDs and their foreign keys survive intact. - routes/auth.ts is gone; Better Auth serves the standard endpoints and authExt.ts carries the flows it doesn't cover. - auth.ts shrinks to session resolution and helpers; sessions/revocation in dashboard.ts now read and delete `auth_sessions` rows directly. - Schema adds the Better Auth core + admin columns (email_verified, image, banned, ban_reason, ban_expires), with migrations and tests. - rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES. - passwordPolicy.ts centralises password validation. - Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible with Better Auth. Frontend: - auth-client.ts plus a reworked AuthContext and api/client.ts move to cookie-based sessions; no more bearer tokens in requests or middleware. photo-api: - Validate Better Auth session cookies against the shared auth_sessions table instead of verifying JWTs; JWT_SECRET is no longer needed for user auth, and PHOTO_VIEW_SECRET now signs gallery view tokens. BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the deprecated JWT_SECRET stays only as the photo-api view-token fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4afa5d6fa0
commit
733d2459df
+132
-124
@@ -7,7 +7,9 @@ import { logger } from 'hono/logger';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import { auth } from './lib/betterAuth.js';
|
||||
import authExtRoutes from './routes/authExt.js';
|
||||
import { getClientIp } from './lib/rateLimit.js';
|
||||
import eventsRoutes from './routes/events.js';
|
||||
import ticketsRoutes from './routes/tickets.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
@@ -57,7 +59,7 @@ app.use(
|
||||
if (!origin) return frontendUrl;
|
||||
return allowedOrigins.has(origin) ? origin : null;
|
||||
},
|
||||
// We use bearer tokens, but keeping credentials=true matches nginx config.
|
||||
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
@@ -110,11 +112,15 @@ const openApiSpec = {
|
||||
],
|
||||
paths: {
|
||||
// ==================== Auth Endpoints ====================
|
||||
'/api/auth/register': {
|
||||
// Authentication is handled by Better Auth, mounted at /api/auth/*.
|
||||
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
|
||||
// below are the subset the frontend uses. See https://better-auth.com/docs
|
||||
// for the full endpoint reference.
|
||||
'/api/auth/sign-up/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register a new user',
|
||||
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
|
||||
summary: 'Register a new user (Better Auth)',
|
||||
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -124,8 +130,8 @@ const openApiSpec = {
|
||||
required: ['email', 'password', 'name'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
|
||||
name: { type: 'string', minLength: 2 },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
name: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
||||
},
|
||||
@@ -134,16 +140,17 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'User created successfully' },
|
||||
400: { description: 'Email already registered or validation error' },
|
||||
200: { description: 'User created; session cookie set' },
|
||||
422: { description: 'Email already registered' },
|
||||
400: { description: 'Validation error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/login': {
|
||||
'/api/auth/sign-in/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login with email and password',
|
||||
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
|
||||
summary: 'Login with email and password (Better Auth)',
|
||||
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -160,42 +167,46 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful, returns JWT token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid credentials' },
|
||||
429: { description: 'Too many login attempts' },
|
||||
429: { description: 'Too many attempts' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/google': {
|
||||
'/api/auth/sign-in/social': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login or register with Google',
|
||||
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
|
||||
summary: 'Login or register with Google (Better Auth)',
|
||||
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['credential'],
|
||||
required: ['provider'],
|
||||
properties: {
|
||||
credential: { type: 'string', description: 'Google ID token' },
|
||||
provider: { type: 'string', enum: ['google'] },
|
||||
idToken: {
|
||||
type: 'object',
|
||||
properties: { token: { type: 'string', description: 'Google ID token' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
400: { description: 'Invalid Google token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid Google token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/request': {
|
||||
'/api/auth/sign-in/magic-link': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request magic link login',
|
||||
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
|
||||
summary: 'Request magic link login (Better Auth)',
|
||||
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -205,46 +216,33 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
callbackURL: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Magic link sent (if account exists)' },
|
||||
},
|
||||
responses: { 200: { description: 'Magic link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/verify': {
|
||||
post: {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Verify magic link token',
|
||||
description: 'Verify the magic link token and login user.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
summary: 'Verify magic link token (Better Auth)',
|
||||
parameters: [
|
||||
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
400: { description: 'Invalid or expired token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/request': {
|
||||
'/api/auth/request-password-reset': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request password reset',
|
||||
description: 'Send a password reset link to email. Link expires in 30 minutes.',
|
||||
summary: 'Request password reset (Better Auth)',
|
||||
description: 'Emails a reset link. Token expires in 30 minutes.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -254,31 +252,30 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
redirectTo: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Reset link sent (if account exists)' },
|
||||
},
|
||||
responses: { 200: { description: 'Reset link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/confirm': {
|
||||
'/api/auth/reset-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm password reset',
|
||||
description: 'Reset password using the token from email.',
|
||||
summary: 'Reset password with token (Better Auth)',
|
||||
description: 'Sets a new password and revokes all existing sessions.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
required: ['newPassword', 'token'],
|
||||
properties: {
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -290,62 +287,10 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/request': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request account claim link',
|
||||
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Claim link sent (if unclaimed account exists)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/confirm': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm account claim',
|
||||
description: 'Claim an unclaimed account by setting password or linking Google.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
|
||||
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Invalid token or missing credentials' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/change-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Change password',
|
||||
description: 'Change password for authenticated user.',
|
||||
summary: 'Change password (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -357,6 +302,7 @@ const openApiSpec = {
|
||||
properties: {
|
||||
currentPassword: { type: 'string' },
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
revokeOtherSessions: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -369,26 +315,66 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/me': {
|
||||
'/api/auth/get-session': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Get current user',
|
||||
description: 'Get the currently authenticated user profile.',
|
||||
summary: 'Get current session and user (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: { description: 'Current user data' },
|
||||
401: { description: 'Unauthorized' },
|
||||
200: { description: '{ session, user } or null when not authenticated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/logout': {
|
||||
'/api/auth/sign-out': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout',
|
||||
description: 'Logout current user (client-side token removal).',
|
||||
responses: {
|
||||
200: { description: 'Logged out' },
|
||||
summary: 'Logout (Better Auth)',
|
||||
description: 'Revokes the current session and clears the session cookie.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Logged out' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/list-sessions': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'List active sessions (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Active sessions for the current user' } },
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-account': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Claim a guest-created account',
|
||||
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['password'],
|
||||
properties: { password: { type: 'string', minLength: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Already claimed or validation error' },
|
||||
401: { description: 'No session (claim link required)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-eligibility': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Check whether an email has an unclaimed account',
|
||||
parameters: [
|
||||
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
|
||||
],
|
||||
responses: { 200: { description: '{ canClaim: boolean }' } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1762,10 +1748,10 @@ const openApiSpec = {
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token obtained from login endpoint',
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'spanglish.session_token',
|
||||
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
@@ -1899,7 +1885,29 @@ app.get('/health', (c) => {
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.route('/api/auth', authRoutes);
|
||||
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
|
||||
// password reset, Google, session management). CORS above runs first.
|
||||
//
|
||||
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
|
||||
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
|
||||
// private header. The inbound value is always discarded — a client cannot
|
||||
// choose its own rate-limit bucket.
|
||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete('x-client-ip');
|
||||
const clientIp = getClientIp(c);
|
||||
if (clientIp && clientIp !== 'unknown') {
|
||||
headers.set('x-client-ip', clientIp);
|
||||
}
|
||||
return auth.handler(
|
||||
new Request(c.req.raw, {
|
||||
headers,
|
||||
// Node's fetch requires duplex for requests carrying a body stream
|
||||
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
|
||||
} as RequestInit)
|
||||
);
|
||||
});
|
||||
app.route('/api/auth-ext', authExtRoutes);
|
||||
app.route('/api/events', eventsRoutes);
|
||||
app.route('/api/tickets', ticketsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
|
||||
Reference in New Issue
Block a user