Fix Google sign-in for existing email and ticket-buyer accounts.

Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-23 05:31:02 +00:00
co-authored by Claude Opus 5
parent 3f7b2d51db
commit 87bf9a6151
9 changed files with 402 additions and 22 deletions
+227 -1
View File
@@ -3,6 +3,8 @@ import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { normalizeEmail } from './utils.js';
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
@@ -13,7 +15,13 @@ 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;
// Google IS configured here: account linking is the whole point of the tests at
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
// when this is unset. No real credentials are involved — the id tokens are
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
@@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string {
return decodeURIComponent(match![1]);
}
// ---- Google Identity Services stub -------------------------------------
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
// Google's published JWKS; its only network call is that JWKS fetch. Signing
// with our own key and serving our own JWKS exercises the real verification
// path without touching the network or needing OAuth credentials.
const GOOGLE_KID = 'spanglish-test-key';
let googlePrivateKey: CryptoKey;
async function installGoogleStub() {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
googlePrivateKey = privateKey as CryptoKey;
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
return new Response(JSON.stringify({ keys: [jwk] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return realFetch(input, init);
}) as typeof fetch;
}
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
return new SignJWT({
email: opts.email,
email_verified: opts.emailVerified ?? true,
name: opts.name ?? 'Google User',
picture: 'https://example.test/avatar.png',
})
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience(GOOGLE_CLIENT_ID)
.setSubject(opts.sub)
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
}
async function signInWithGoogle(
opts: Parameters<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
const token = await googleIdToken(opts);
return auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
...(returnHeaders ? { returnHeaders: true } : {}),
} as any);
}
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
* unclaimed, unverified, and with no auth_accounts row at all. */
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
)
.run(id, email, name, now, now);
return id;
}
function userRow(email: string) {
return sqlite
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
.get(email);
}
function googleAccountsFor(userId: string) {
return sqlite
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
.all(userId);
}
function cookieHeaders(setCookie: string | null): Headers {
const sessionPart = (setCookie || '')
.split(/,(?=[^ ;]+=)/)
@@ -60,6 +147,7 @@ beforeAll(() => {
({ db } = await import('../db/index.js'));
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
await installGoogleStub();
})();
}, 120_000);
@@ -291,3 +379,141 @@ describe('Better Auth integration', () => {
expect(days).toBeLessThan(7.5);
});
});
describe('Google sign-in and account linking', () => {
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
expect(userRow('buyer@test.py').email_verified).toBe(0);
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
});
it('claims the booking account so the resulting session is actually accepted', async () => {
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
// 'active', so linking alone would leave the user looking logged out.
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
const { headers, response } = await signInWithGoogle(
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
true
);
expect(response.user.id).toBe(id);
const row = userRow('buyer2@test.py');
expect(row.account_status).toBe('active');
expect(row.is_claimed).toBe(1);
expect(row.email_verified).toBe(1);
const session = await auth.api.getSession({
headers: cookieHeaders(headers.get('set-cookie')),
});
expect(session?.user.id).toBe(id);
expect((session?.user as any).accountStatus).toBe('active');
});
it('never reactivates a suspended account through a Google link', async () => {
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
)
.run('suspended-google', 'suspended-google@test.py', now, now);
await expect(
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
).rejects.toThrow();
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
});
it('links Google onto an email/password account created after the Better Auth migration', async () => {
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
// is off), which used to be enough to block linking on its own.
await auth.api.signUpEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
});
expect(userRow('pwuser@test.py').email_verified).toBe(0);
const id = userRow('pwuser@test.py').id;
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
expect(res.user.id).toBe(id);
expect(googleAccountsFor(id)).toHaveLength(1);
// The credential account survives: they can still sign in with a password.
const after = await auth.api.signInEmail({
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
});
expect(after.user.id).toBe(id);
});
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
const row = userRow('fresh@test.py');
expect(row.id).toBe(first.user.id);
expect(row.email_verified).toBe(1);
expect(row.account_status).toBe('active');
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
expect(second.user.id).toBe(first.user.id);
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
});
it('rejects an id token minted for a different client id', async () => {
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
.setIssuer('https://accounts.google.com')
.setAudience('some-other-app.apps.googleusercontent.com')
.setSubject('google-sub-forged')
.setIssuedAt()
.setExpirationTime('10m')
.sign(googlePrivateKey);
await expect(
auth.api.signInSocial({
body: { provider: 'google', idToken: { token } },
headers: new Headers(),
} as any)
).rejects.toThrow();
expect(userRow('forged@test.py')).toBeUndefined();
});
});
describe('users.email normalization', () => {
it('normalizes an address to its canonical stored form', () => {
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
});
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
const now = new Date().toISOString();
const insert = (id: string, email: string, status = 'unclaimed') =>
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
)
.run(id, email, status, now, now);
insert('legacy-mixed', 'John@Example.com');
// A pair that genuinely collides: the migration must leave both alone.
insert('legacy-dup-lower', 'dup@example.com');
insert('legacy-dup-mixed', 'Dup@Example.com');
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
expect(userRow('john@example.com').id).toBe('legacy-mixed');
expect(userRow('John@Example.com')).toBeUndefined();
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
// ...and the lowercased row is now reachable by Google sign-in.
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
expect(res.user.id).toBe('legacy-mixed');
}, 120_000);
});