M1: authentication with better-auth, verified end to end

Switches from the planned Auth.js v5 to better-auth 1.7.1. The plan
assumed the blocker would be schema fit; it is not. @auth/drizzle-adapter
accepts our tables verbatim. What rules Auth.js out is that credentials
providers hardcode JWT and never call adapter.createSession, and the
config assertion that would catch it only fires when EVERY provider is
credentials — so adding Google suppresses the warning and the app ships
silently broken. Phone OTP with database sessions is not reachable there
without hand-building the whole OTP security layer.

Also corrects a premise: better-auth's drizzle-orm peer is declared
OPTIONAL, so no 0.38 -> 0.45 upgrade is forced. Verified on 0.38.4.

- auth schema rewritten to better-auth 1.7.1's own getSchema() output:
  sessions/accounts/verifications reshaped, emailVerified and
  phoneVerified are BOOLEAN (a timestamptz there fails 100% of signups),
  accounts.issuer added, phone_otps dropped. Ban state now comes from the
  admin plugin rather than a second bannedAt column.
- Session resolution is one file. Everything downstream is written
  against our own Session type, so the provider stays swappable.
- Ban enforcement lives in the resolver because Session carries no ban
  field and protectedProcedure promises a non-banned user.
- Phone OTP sign-in, Google, role selection, tRPC user router.
- Synthetic emails for phone-first users, with isSyntheticEmail() gating
  every future send. Pros must supply a real address; clients need not.
- Duplicate-account detection, since both signup routes stay open and
  nothing correlates a phone to a Google identity. Detects only — merging
  accounts that carry reviews and payments needs its own tooling.
- SMS sender refuses to fall back to console logging in production.
- declaration:false for the app, which is the actual fix for the TS2742
  wall from better-auth's transitive zod under pnpm.

Verified against a live server: OTP sent, code verified, uuid PK honoured,
database session written, and an authenticated tRPC call resolved. A
signed-in stranger gets NOT_FOUND on another client's deck; anonymous
gets UNAUTHORIZED.

124 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-20 14:40:23 -04:00
co-authored by Claude Opus 5
parent 66dd4ac942
commit cebeda7f4c
32 changed files with 1873 additions and 397 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* Auth integration test — runs against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/web test
*
* This is deliberately an integration test rather than a unit test, because the
* thing most likely to break is not our logic. better-auth declares
* `drizzle-orm: "^0.45.2 || >=1.0.0-rc.1"` as an OPTIONAL peer and we run 0.38.4,
* which is outside that range but verified compatible. A future better-auth
* patch could start relying on a 0.45-only API and nothing in the type system
* would catch it. This test is the tripwire: if signup stops writing rows, CI
* goes red instead of production going quiet.
*/
import { config } from 'dotenv';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db, schema } = await import('@linkder/db');
const { auth } = await import('@/lib/auth');
const { isSyntheticEmail } = await import('@linkder/shared');
/** A number no seed row uses, so the test owns its own user. */
const PHONE = '+34699000111';
/** better-auth stores the OTP as "123456:0" — code, then attempt count. */
async function readOtp(identifier: string): Promise<string> {
const rows = await db
.select()
.from(schema.verifications)
.where(eq(schema.verifications.identifier, identifier));
const row = rows.at(-1);
if (!row) throw new Error(`no verification row for ${identifier}`);
const code = row.value.split(':')[0];
if (!code) throw new Error(`unparseable verification value: ${row.value}`);
return code;
}
async function cleanup() {
const existing = await db.query.users.findFirst({
where: eq(schema.users.phoneNumber, PHONE),
columns: { id: true },
});
if (existing) await db.delete(schema.users).where(eq(schema.users.id, existing.id));
await db.delete(schema.verifications).where(eq(schema.verifications.identifier, PHONE));
}
beforeAll(cleanup);
afterAll(async () => {
await cleanup();
await closePool();
});
describe('phone OTP signup', () => {
let userId: string;
let sessionToken: string;
it('sends a code and stores it against the number', async () => {
const sent = await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
expect(sent).toBeTruthy();
const code = await readOtp(PHONE);
expect(code).toMatch(/^\d{6}$/);
});
it('creates a user with a real uuid primary key', async () => {
const code = await readOtp(PHONE);
const result = await auth.api.verifyPhoneNumber({
body: { phoneNumber: PHONE, code },
});
expect(result?.user).toBeTruthy();
userId = result!.user.id;
sessionToken = result!.token!;
// generateId:false must be honoured — better-auth's own id generator would
// write a non-uuid string and every FK in the schema would reject it.
expect(userId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('marks the number verified and defaults the role to client', async () => {
const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) });
expect(user?.phoneNumberVerified).toBe(true);
// Not "user" — that is better-auth's default and is not in our enum.
expect(user?.role).toBe('client');
});
it('mints a synthetic email that we know not to send to', async () => {
const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) });
expect(user?.email).toBe(`${PHONE}@phone.linkder.local`);
expect(isSyntheticEmail(user!.email)).toBe(true);
});
it('writes a real database session rather than a JWT', async () => {
// The whole reason for choosing better-auth: Auth.js credentials providers
// hardcode JWT and never call createSession.
const sessions = await db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.userId, userId));
expect(sessions.length).toBeGreaterThan(0);
expect(sessions[0]!.token).toBe(sessionToken);
expect(sessions[0]!.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it('resolves that session into our own Session type', async () => {
const { resolveSession } = await import('@/server/session');
const session = await resolveSession(
new Request('http://localhost/rsc', {
headers: { cookie: `better-auth.session_token=${sessionToken}` },
}),
);
// The cookie is signed, so a bare token may not resolve — what must hold is
// that the resolver never throws and never invents a session.
if (session) {
expect(session.userId).toBe(userId);
expect(session.role).toBe('client');
}
});
it('rejects a wrong code', async () => {
await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
await expect(
auth.api.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }),
).rejects.toThrow();
});
it('locks out after the configured attempt cap', async () => {
await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } });
// allowedAttempts: 3 — the fourth must fail even with the right code.
for (let i = 0; i < 3; i++) {
await auth.api
.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } })
.catch(() => undefined);
}
const rows = await db
.select()
.from(schema.verifications)
.where(eq(schema.verifications.identifier, PHONE));
// Either the row is consumed, or its attempt counter is exhausted.
const exhausted =
rows.length === 0 || rows.every((r) => Number(r.value.split(':')[1] ?? 0) >= 3);
expect(exhausted).toBe(true);
});
});
describe('session resolver', () => {
it('returns null for an anonymous request instead of throwing', async () => {
const { resolveSession } = await import('@/server/session');
await expect(
resolveSession(new Request('http://localhost/rsc')),
).resolves.toBeNull();
});
it('returns null for a garbage cookie', async () => {
const { resolveSession } = await import('@/server/session');
await expect(
resolveSession(
new Request('http://localhost/rsc', {
headers: { cookie: 'better-auth.session_token=not-a-real-token' },
}),
),
).resolves.toBeNull();
});
});