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>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
import { execFileSync } from 'child_process';
|
|
import { mkdtempSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
|
|
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
|
// (dotenv never overrides pre-set values).
|
|
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
|
|
const dbPath = join(dir, 'test.db');
|
|
process.env.DB_TYPE = 'sqlite';
|
|
process.env.DATABASE_URL = dbPath;
|
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
|
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
|
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
|
delete process.env.GOOGLE_CLIENT_ID;
|
|
|
|
// Capture outgoing auth emails (magic links, password resets)
|
|
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
|
vi.mock('./email.js', () => ({
|
|
sendEmail: vi.fn(async (opts: any) => {
|
|
sentEmails.push(opts);
|
|
}),
|
|
emailService: {},
|
|
default: {},
|
|
}));
|
|
|
|
let auth: (typeof import('./betterAuth.js'))['auth'];
|
|
let db: any;
|
|
let sqlite: any;
|
|
|
|
function lastEmailTo(email: string) {
|
|
const found = [...sentEmails].reverse().find((e) => e.to === email);
|
|
expect(found, `expected an email sent to ${email}`).toBeTruthy();
|
|
return found!;
|
|
}
|
|
|
|
function extractToken(html: string, param = 'token'): string {
|
|
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
|
|
expect(match, `expected a ${param} in the email link`).toBeTruthy();
|
|
return decodeURIComponent(match![1]);
|
|
}
|
|
|
|
function cookieHeaders(setCookie: string | null): Headers {
|
|
const sessionPart = (setCookie || '')
|
|
.split(/,(?=[^ ;]+=)/)
|
|
.map((c) => c.split(';')[0].trim())
|
|
.filter((c) => c.includes('session_token'))
|
|
.join('; ');
|
|
return new Headers({ cookie: sessionPart });
|
|
}
|
|
|
|
beforeAll(() => {
|
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
|
env: { ...process.env },
|
|
stdio: 'pipe',
|
|
});
|
|
return (async () => {
|
|
({ auth } = await import('./betterAuth.js'));
|
|
({ db } = await import('../db/index.js'));
|
|
const Database = (await import('better-sqlite3')).default;
|
|
sqlite = new Database(dbPath);
|
|
})();
|
|
}, 120_000);
|
|
|
|
describe('Better Auth integration', () => {
|
|
it('makes the first registered user an admin, later users regular', async () => {
|
|
const first = await auth.api.signUpEmail({
|
|
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
|
|
});
|
|
expect((first.user as any).id).toBeTruthy();
|
|
|
|
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
|
|
expect(row.role).toBe('admin');
|
|
expect(row.account_status).toBe('active');
|
|
|
|
await auth.api.signUpEmail({
|
|
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
|
|
});
|
|
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
|
|
expect(row2.role).toBe('user');
|
|
});
|
|
|
|
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
|
|
const acct = sqlite
|
|
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
|
|
.get('admin@test.py');
|
|
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
|
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
|
|
expect(user.password).toBeNull();
|
|
});
|
|
|
|
it('rejects passwords that violate the policy', async () => {
|
|
// Too short
|
|
await expect(
|
|
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
|
|
).rejects.toThrow(/at least 10 characters/);
|
|
|
|
// Long enough but no character mix (app policy hook)
|
|
await expect(
|
|
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
|
|
).rejects.toThrow(/uppercase and lowercase/);
|
|
|
|
// Common password normalized (policy blocklist)
|
|
await expect(
|
|
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
|
|
).rejects.toThrow(/too common/);
|
|
|
|
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
|
|
});
|
|
|
|
it('locks an email after 5 failed sign-ins', async () => {
|
|
await auth.api.signUpEmail({
|
|
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
|
|
});
|
|
for (let i = 0; i < 5; i++) {
|
|
await expect(
|
|
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
|
|
).rejects.toThrow();
|
|
}
|
|
// Correct password now also refused: locked
|
|
await expect(
|
|
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
|
|
).rejects.toThrow(/Too many login attempts/);
|
|
});
|
|
|
|
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
|
|
await auth.api.signUpEmail({
|
|
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
|
|
});
|
|
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
|
|
await expect(
|
|
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
|
|
).rejects.toThrow(/suspended|banned/i);
|
|
});
|
|
|
|
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
|
|
const bcrypt = (await import('bcryptjs')).default;
|
|
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
|
|
const su = await auth.api.signUpEmail({
|
|
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
|
|
});
|
|
sqlite
|
|
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
|
|
.run(legacyHash, (su.user as any).id);
|
|
|
|
const si = await auth.api.signInEmail({
|
|
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
|
});
|
|
expect(si.user.email).toBe('legacy@test.py');
|
|
|
|
// Upgrade happens in the after-hook; poll briefly for it
|
|
let upgraded = '';
|
|
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
upgraded = sqlite
|
|
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
|
.get((su.user as any).id).password;
|
|
}
|
|
expect(upgraded.startsWith('$argon2id$')).toBe(true);
|
|
|
|
// And the upgraded hash still verifies
|
|
const again = await auth.api.signInEmail({
|
|
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
|
});
|
|
expect(again.user.email).toBe('legacy@test.py');
|
|
});
|
|
|
|
it('magic link signs in existing users but never creates accounts', async () => {
|
|
await auth.api.signUpEmail({
|
|
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
|
|
});
|
|
await auth.api.signInMagicLink({
|
|
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
|
|
headers: new Headers(),
|
|
});
|
|
const email = lastEmailTo('magic@test.py');
|
|
// Emails link to the frontend page, not the raw API endpoint
|
|
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
|
|
const token = extractToken(email.html);
|
|
|
|
const verified = await auth.api.magicLinkVerify({
|
|
query: { token },
|
|
headers: new Headers(),
|
|
});
|
|
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
|
|
|
|
// Unknown email: enumeration-safe success (an email may still go out),
|
|
// but verification can never create an account (disableSignUp)
|
|
const ghost = await auth.api.signInMagicLink({
|
|
body: { email: 'ghost@test.py' },
|
|
headers: new Headers(),
|
|
});
|
|
expect((ghost as any).status).toBe(true);
|
|
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
|
|
if (ghostEmail) {
|
|
const ghostToken = extractToken(ghostEmail.html);
|
|
await expect(
|
|
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
|
|
).rejects.toThrow();
|
|
}
|
|
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
|
|
});
|
|
|
|
it('completes the guest claim path: magic link session + setPassword', async () => {
|
|
// Simulate tickets.ts guest creation: user row, no credential account
|
|
const guestId = 'guest-claim-user-000001';
|
|
const now = new Date().toISOString();
|
|
sqlite
|
|
.prepare(
|
|
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
|
|
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
|
|
)
|
|
.run(guestId, 'guest@test.py', now, now);
|
|
|
|
await auth.api.signInMagicLink({
|
|
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
|
|
headers: new Headers(),
|
|
});
|
|
const token = extractToken(lastEmailTo('guest@test.py').html);
|
|
const verified = await auth.api.magicLinkVerify({
|
|
query: { token },
|
|
returnHeaders: true,
|
|
headers: new Headers(),
|
|
});
|
|
const headers = cookieHeaders(verified.headers.get('set-cookie'));
|
|
|
|
// The session works even while unclaimed (the claim endpoint depends on this)
|
|
const session = await auth.api.getSession({ headers });
|
|
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
|
|
|
|
// Set the password (what /api/auth-ext/claim-account does)
|
|
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
|
|
const acct = sqlite
|
|
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
|
.get(guestId);
|
|
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
|
|
|
// The claim email uses the claim template with the frontend link
|
|
const claimEmail = lastEmailTo('guest@test.py');
|
|
expect(claimEmail.subject).toContain('Claim');
|
|
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
|
|
});
|
|
|
|
it('password reset revokes existing sessions and applies the new password', async () => {
|
|
const su = await auth.api.signUpEmail({
|
|
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
|
|
});
|
|
const userId = (su.user as any).id;
|
|
// A live session from sign-in
|
|
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
|
|
expect(
|
|
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
|
).toBeGreaterThan(0);
|
|
|
|
await auth.api.requestPasswordReset({
|
|
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
|
|
});
|
|
const email = lastEmailTo('reset@test.py');
|
|
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
|
|
const html = email.html;
|
|
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
|
|
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
|
|
|
|
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
|
|
|
|
// revokeSessionsOnPasswordReset: true
|
|
expect(
|
|
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
|
).toBe(0);
|
|
|
|
await expect(
|
|
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
|
|
).rejects.toThrow();
|
|
const after = await auth.api.signInEmail({
|
|
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
|
|
});
|
|
expect(after.user.email).toBe('reset@test.py');
|
|
});
|
|
|
|
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
|
|
const si = await auth.api.signInEmail({
|
|
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
|
|
});
|
|
const row = sqlite
|
|
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
|
|
.get((si as any).token);
|
|
expect(row).toBeTruthy();
|
|
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
|
|
expect(days).toBeGreaterThan(6.5);
|
|
expect(days).toBeLessThan(7.5);
|
|
});
|
|
});
|