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>
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
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, 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
|
|
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
|
|
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
|
|
|
|
const authExt = new Hono();
|
|
|
|
const claimAccountSchema = z.object({
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
// Complete a progressive-account claim. The user arrives here already holding
|
|
// a session established by the claim magic link; this endpoint deliberately
|
|
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
|
|
// and is the ONLY endpoint that does.
|
|
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
if (!session?.user) {
|
|
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
|
|
}
|
|
|
|
const user = session.user as any;
|
|
if (user.banned || user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
if (user.isClaimed && user.accountStatus === 'active') {
|
|
return c.json({ error: 'Account is already claimed' }, 400);
|
|
}
|
|
|
|
const { password } = c.req.valid('json');
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
|
|
await auth.api.setPassword({
|
|
body: { newPassword: password },
|
|
headers: c.req.raw.headers,
|
|
});
|
|
|
|
// The magic link click proved email ownership
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
emailVerified: true,
|
|
updatedAt: getNow(),
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
return c.json({
|
|
message: 'Account claimed successfully!',
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: true,
|
|
phone: user.phone ?? null,
|
|
rucNumber: user.rucNumber ?? null,
|
|
languagePreference: user.languagePreference ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
|
|
// exception to enumeration-safety, matching the legacy register/login UX that
|
|
// surfaced "this account can be claimed".
|
|
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
|
const email = c.req.query('email');
|
|
if (!email || !z.string().email().safeParse(email).success) {
|
|
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, normalizeEmail(email)))
|
|
);
|
|
|
|
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
|
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
|
|
|
|
return c.json({ canClaim });
|
|
});
|
|
|
|
export default authExt;
|