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:
co-authored by
Claude Opus 5
parent
3f7b2d51db
commit
87bf9a6151
@@ -1,6 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import { db, dbAll, events } from './index.js';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
import { db, dbAll, dbGet, events, users } from './index.js';
|
||||
import { sql, eq, ne } from 'drizzle-orm';
|
||||
import { uniqueSlug } from '../lib/slugify.js';
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
@@ -1344,6 +1344,57 @@ async function migrate() {
|
||||
`);
|
||||
}
|
||||
|
||||
// ==================== users.email normalization ====================
|
||||
// Better Auth lowercases the address on every lookup and write it performs,
|
||||
// but the users.email unique index is case-sensitive on both dialects. Rows
|
||||
// written outside Better Auth (guest bookings, door sales, admin-added
|
||||
// tickets) used to keep the address exactly as typed, so a buyer who entered
|
||||
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
|
||||
// signing in with Google minted a SECOND user row and left their tickets
|
||||
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
|
||||
// fixes the rows already in the table.
|
||||
//
|
||||
// Idempotent, and deliberately conservative: a row is only lowercased when
|
||||
// nothing already occupies the lowercase address. A genuine collision means
|
||||
// two user rows for the same person, each with its own tickets, invoices and
|
||||
// payments — merging those is a judgement call, not a migration, so they are
|
||||
// reported for manual review instead.
|
||||
const lowercaseEmailsSql = `
|
||||
UPDATE users SET email = LOWER(email)
|
||||
WHERE email <> LOWER(email)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
|
||||
)
|
||||
`;
|
||||
if (dbType === 'sqlite') {
|
||||
await (db as any).run(sql.raw(lowercaseEmailsSql));
|
||||
} else {
|
||||
await (db as any).execute(sql.raw(lowercaseEmailsSql));
|
||||
}
|
||||
|
||||
// Whatever still differs from its own lowercase form is exactly the set the
|
||||
// UPDATE refused to touch, i.e. the collisions.
|
||||
const collisions = await dbAll<{ id: string; email: string }>(
|
||||
(db as any)
|
||||
.select({ id: (users as any).id, email: (users as any).email })
|
||||
.from(users)
|
||||
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
|
||||
);
|
||||
if (collisions.length > 0) {
|
||||
console.warn(
|
||||
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
|
||||
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
|
||||
`the lowercase row, so these need a manual merge:`
|
||||
);
|
||||
for (const row of collisions) {
|
||||
const canonical = row.email.toLowerCase();
|
||||
const existing = await dbGet<{ id: string }>(
|
||||
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
|
||||
);
|
||||
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||
|
||||
Reference in New Issue
Block a user