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
+3 -2
View File
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
import { auth } from '../lib/betterAuth.js';
import { validatePassword } from '../lib/passwordPolicy.js';
import { db, dbGet, users } from '../db/index.js';
import { getNow, toDbBool } from '../lib/utils.js';
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
@@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
return c.json({ canClaim: false });
}
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
+9 -4
View File
@@ -23,7 +23,7 @@ import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
@@ -370,9 +370,14 @@ doorRouter.post(
// No email is the fast path; a placeholder keeps the users.email unique
// constraint satisfied without ever mailing anyone.
const accountEmail = hasEmail
? attendee.email!.trim()
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`;
// Normalized: Better Auth lowercases every lookup it makes, and the
// users.email unique index is case-sensitive, so a mixed-case address
// written here would be invisible to sign-in and Google linking.
const accountEmail = normalizeEmail(
hasEmail
? attendee.email!
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
);
let user = hasEmail
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
+15 -10
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
@@ -148,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400);
}
// Find or create user
// Find or create user. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const now = getNow();
@@ -163,7 +166,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId();
user = {
id: userId,
email: data.email,
email: accountEmail,
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName,
phone: data.phone || null,
@@ -1425,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const adminFullName = data.lastName && data.lastName.trim()
@@ -1438,7 +1442,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: adminFullName,
phone: data.phone || null,
@@ -1585,16 +1589,17 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
? `${firstName} ${data.lastName.trim()}`
: firstName;
// Find or create user
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
email: accountEmail,
password: null,
name: fullName,
phone: data.phone || null,