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>
201 lines
7.8 KiB
TypeScript
201 lines
7.8 KiB
TypeScript
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
|
|
import {
|
|
pgTable,
|
|
uuid,
|
|
varchar,
|
|
text as pgText,
|
|
timestamp,
|
|
boolean as pgBoolean,
|
|
bigint,
|
|
customType as pgCustomType,
|
|
} from 'drizzle-orm/pg-core';
|
|
|
|
// Better Auth table definitions for both dialects.
|
|
//
|
|
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
|
|
// foreign key that references them) survive the auth migration untouched. Only
|
|
// the columns Better Auth reads/writes are declared here; legacy columns
|
|
// (password, google_id, token_version) stay physically present but invisible
|
|
// to Better Auth. The full application-facing definition lives in schema.ts —
|
|
// two Drizzle table objects can safely describe the same SQL table.
|
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
|
|
|
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
|
|
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
|
|
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
|
|
dataType() {
|
|
return 'text';
|
|
},
|
|
toDriver(value: Date): string {
|
|
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
},
|
|
fromDriver(value: string): Date {
|
|
return new Date(value);
|
|
},
|
|
});
|
|
|
|
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
|
|
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
|
|
dataType() {
|
|
return 'integer';
|
|
},
|
|
toDriver(value: boolean): number {
|
|
return value ? 1 : 0;
|
|
},
|
|
fromDriver(value: number | boolean): boolean {
|
|
return Boolean(value);
|
|
},
|
|
});
|
|
|
|
// ==================== SQLite ====================
|
|
|
|
export const sqliteAuthUsers = sqliteTable('users', {
|
|
id: text('id').primaryKey(),
|
|
name: text('name').notNull(),
|
|
email: text('email').notNull().unique(),
|
|
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
|
image: text('image'),
|
|
createdAt: isoText('created_at').notNull(),
|
|
updatedAt: isoText('updated_at').notNull(),
|
|
// admin plugin fields
|
|
role: text('role').notNull().default('user'),
|
|
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
|
banReason: text('ban_reason'),
|
|
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
|
// application additionalFields
|
|
phone: text('phone'),
|
|
languagePreference: text('language_preference'),
|
|
rucNumber: text('ruc_number'),
|
|
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
|
|
accountStatus: text('account_status').notNull().default('active'),
|
|
});
|
|
|
|
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
|
|
id: text('id').primaryKey(),
|
|
userId: text('user_id')
|
|
.notNull()
|
|
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
|
token: text('token').notNull().unique(),
|
|
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
ipAddress: text('ip_address'),
|
|
userAgent: text('user_agent'),
|
|
// admin plugin (impersonation)
|
|
impersonatedBy: text('impersonated_by'),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
});
|
|
|
|
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
|
|
id: text('id').primaryKey(),
|
|
userId: text('user_id')
|
|
.notNull()
|
|
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
|
accountId: text('account_id').notNull(),
|
|
providerId: text('provider_id').notNull(),
|
|
accessToken: text('access_token'),
|
|
refreshToken: text('refresh_token'),
|
|
idToken: text('id_token'),
|
|
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
|
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
|
scope: text('scope'),
|
|
password: text('password'),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
});
|
|
|
|
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
|
|
id: text('id').primaryKey(),
|
|
identifier: text('identifier').notNull(),
|
|
value: text('value').notNull(),
|
|
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
});
|
|
|
|
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
|
|
id: text('id').primaryKey(),
|
|
key: text('key'),
|
|
count: integer('count'),
|
|
lastRequest: integer('last_request'),
|
|
});
|
|
|
|
// ==================== PostgreSQL ====================
|
|
|
|
export const pgAuthUsers = pgTable('users', {
|
|
id: uuid('id').primaryKey(),
|
|
name: varchar('name', { length: 255 }).notNull(),
|
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
|
image: pgText('image'),
|
|
createdAt: timestamp('created_at').notNull(),
|
|
updatedAt: timestamp('updated_at').notNull(),
|
|
// admin plugin fields
|
|
role: varchar('role', { length: 20 }).notNull().default('user'),
|
|
banned: pgBoolean('banned').notNull().default(false),
|
|
banReason: pgText('ban_reason'),
|
|
banExpires: timestamp('ban_expires'),
|
|
// application additionalFields
|
|
phone: varchar('phone', { length: 50 }),
|
|
languagePreference: varchar('language_preference', { length: 10 }),
|
|
rucNumber: varchar('ruc_number', { length: 15 }),
|
|
isClaimed: pgIntBool('is_claimed').notNull(),
|
|
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
|
});
|
|
|
|
export const pgAuthSessions = pgTable('auth_sessions', {
|
|
id: uuid('id').primaryKey(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
|
token: varchar('token', { length: 255 }).notNull().unique(),
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
ipAddress: varchar('ip_address', { length: 45 }),
|
|
userAgent: pgText('user_agent'),
|
|
// admin plugin (impersonation)
|
|
impersonatedBy: uuid('impersonated_by'),
|
|
createdAt: timestamp('created_at').notNull(),
|
|
updatedAt: timestamp('updated_at').notNull(),
|
|
});
|
|
|
|
export const pgAuthAccounts = pgTable('auth_accounts', {
|
|
id: uuid('id').primaryKey(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
|
accountId: varchar('account_id', { length: 255 }).notNull(),
|
|
providerId: varchar('provider_id', { length: 100 }).notNull(),
|
|
accessToken: pgText('access_token'),
|
|
refreshToken: pgText('refresh_token'),
|
|
idToken: pgText('id_token'),
|
|
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
|
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
|
scope: pgText('scope'),
|
|
password: pgText('password'),
|
|
createdAt: timestamp('created_at').notNull(),
|
|
updatedAt: timestamp('updated_at').notNull(),
|
|
});
|
|
|
|
export const pgAuthVerifications = pgTable('auth_verifications', {
|
|
id: uuid('id').primaryKey(),
|
|
identifier: varchar('identifier', { length: 255 }).notNull(),
|
|
value: pgText('value').notNull(),
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
createdAt: timestamp('created_at').notNull(),
|
|
updatedAt: timestamp('updated_at').notNull(),
|
|
});
|
|
|
|
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
|
|
id: varchar('id', { length: 64 }).primaryKey(),
|
|
key: varchar('key', { length: 255 }),
|
|
count: bigint('count', { mode: 'number' }),
|
|
lastRequest: bigint('last_request', { mode: 'number' }),
|
|
});
|
|
|
|
// ==================== Runtime-switched exports ====================
|
|
|
|
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
|
|
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
|
|
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
|
|
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
|
|
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
|