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>
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
|
|
|
|
// Minimal Hono-Context stand-in: headers + the node-server env with the
|
|
// socket peer address.
|
|
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
|
|
const headers = new Map(
|
|
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
|
);
|
|
return {
|
|
req: { header: (name: string) => headers.get(name.toLowerCase()) },
|
|
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
|
|
} as any;
|
|
}
|
|
|
|
describe('isTrustedProxyIp', () => {
|
|
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
|
|
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
|
|
expect(isTrustedProxyIp('::1')).toBe(true);
|
|
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
|
|
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
|
|
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
|
|
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
|
|
});
|
|
|
|
it('does not trust public addresses or near-miss ranges', () => {
|
|
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
|
|
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
|
|
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
|
|
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
|
|
expect(isTrustedProxyIp('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getClientIp', () => {
|
|
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
|
|
const c = fakeContext({
|
|
peer: '127.0.0.1',
|
|
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
|
|
});
|
|
expect(getClientIp(c)).toBe('203.0.113.7');
|
|
});
|
|
|
|
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
|
|
// spoofed prefix, then the real client appended by nginx, then the Next
|
|
// proxy hop — the rightmost untrusted entry wins
|
|
const c = fakeContext({
|
|
peer: '127.0.0.1',
|
|
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
|
|
});
|
|
expect(getClientIp(c)).toBe('203.0.113.7');
|
|
});
|
|
|
|
it('ignores forwarded headers entirely when the peer is untrusted', () => {
|
|
// A client hitting the API directly cannot pick its own bucket
|
|
const c = fakeContext({
|
|
peer: '198.51.100.4',
|
|
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
|
|
});
|
|
expect(getClientIp(c)).toBe('198.51.100.4');
|
|
});
|
|
|
|
it('falls back to the socket address for local traffic with no headers', () => {
|
|
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
|
|
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('falls back to the socket address when every forwarded hop is internal', () => {
|
|
const c = fakeContext({
|
|
peer: '127.0.0.1',
|
|
headers: { 'x-forwarded-for': '127.0.0.1' },
|
|
});
|
|
expect(getClientIp(c)).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('rejects junk header values instead of using them as bucket keys', () => {
|
|
const c = fakeContext({
|
|
peer: '127.0.0.1',
|
|
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
|
|
});
|
|
expect(getClientIp(c)).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('returns "unknown" without a socket address or trusted headers', () => {
|
|
expect(getClientIp(fakeContext({}))).toBe('unknown');
|
|
});
|
|
});
|