From cb9c58f1121d3c4dee7e0dbe88db39615b797a3d Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 23 Aug 2026 13:29:11 +0530 Subject: [PATCH 1/2] fix(auth): build Better Auth per request instead of at module eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit betterAuth() was called at module scope, so the Worker froze its secret, baseURL, and Google credentials at module-evaluation time — before OpenNext populates process.env from the Cloudflare request environment. In production that captured undefined values and left auth non-functional. buildAuthOptions()/getAuth() defer construction to the first request, and callers move from the `auth` singleton to getAuth(). Also adds the Better Auth D1 tables (singular `user`, `session`, `account`, `verification`, kept separate from the app's existing plural `users` table) plus unit tests for the option builder and the signed-out user menu. The sign-in callback moves from '/' to '/dashboard' so a fresh sign-in lands somewhere useful. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/0002_better_auth_tables.sql | 54 +++++++++++++++++ src/__tests__/user-menu.test.tsx | 39 +++++++++++++ src/app/api/auth/[...all]/route.ts | 4 +- src/app/api/checkout/route.ts | 4 +- src/components/user-menu.tsx | 2 +- src/lib/auth-utils.ts | 4 +- src/lib/auth.test.ts | 35 +++++++++++ src/lib/auth.ts | 80 +++++++++++++++++++------- src/lib/db-schema.sql | 54 +++++++++++++++++ 9 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 migrations/0002_better_auth_tables.sql create mode 100644 src/__tests__/user-menu.test.tsx create mode 100644 src/lib/auth.test.ts diff --git a/migrations/0002_better_auth_tables.sql b/migrations/0002_better_auth_tables.sql new file mode 100644 index 0000000..3079044 --- /dev/null +++ b/migrations/0002_better_auth_tables.sql @@ -0,0 +1,54 @@ +-- Better Auth's D1-backed OAuth/session tables. +-- These singular model names intentionally remain separate from the app's +-- existing plural `users` table. +CREATE TABLE IF NOT EXISTS "user" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL DEFAULT 0, + image TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "session" ( + id TEXT PRIMARY KEY, + expiresAt TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" (userId); + +CREATE TABLE IF NOT EXISTS "account" ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt TEXT, + refreshTokenExpiresAt TEXT, + scope TEXT, + password TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_user_id ON "account" (userId); + +CREATE TABLE IF NOT EXISTS "verification" ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" (identifier); diff --git a/src/__tests__/user-menu.test.tsx b/src/__tests__/user-menu.test.tsx new file mode 100644 index 0000000..a186341 --- /dev/null +++ b/src/__tests__/user-menu.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { UserMenu } from '@/components/user-menu'; + +const mockSignInSocial = vi.fn(); + +vi.mock('@/lib/auth-client', () => ({ + authClient: { + useSession: () => ({ data: null }), + signIn: { + social: (...args: unknown[]) => mockSignInSocial(...args), + }, + }, +})); + +vi.mock('@/lib/foundry-monitoring', () => ({ + captureAuthFailure: vi.fn(), +})); + +beforeEach(() => { + mockSignInSocial.mockReset(); + mockSignInSocial.mockResolvedValue({}); +}); + +describe('UserMenu', () => { + it('returns Google sign-in to the dashboard', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Sign in' })); + + expect(mockSignInSocial).toHaveBeenCalledWith({ + provider: 'google', + callbackURL: '/dashboard', + }); + }); +}); diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 50be029..217a59b 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,5 +1,5 @@ import { toNextJsHandler } from 'better-auth/next-js'; -import { auth } from '@/lib/auth'; +import { getAuth } from '@/lib/auth'; -export const { GET, POST } = toNextJsHandler(auth.handler); +export const { GET, POST } = toNextJsHandler((request) => getAuth().handler(request)); diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index e627953..892c6f3 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -3,7 +3,7 @@ import { headers } from 'next/headers'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { auth } from '@/lib/auth'; +import { getAuth } from '@/lib/auth'; import { getProductId } from '@/lib/token-config'; let _client: DodoPayments | null = null; @@ -61,7 +61,7 @@ function checkoutErrorResponse(error: unknown) { } export async function POST(request: NextRequest) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getAuth().api.getSession({ headers: await headers() }); if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } diff --git a/src/components/user-menu.tsx b/src/components/user-menu.tsx index 59538fe..49f2cd0 100644 --- a/src/components/user-menu.tsx +++ b/src/components/user-menu.tsx @@ -25,7 +25,7 @@ export function UserMenu() { if (!session?.user) { function handleSignIn() { authClient.signIn - .social({ provider: 'google', callbackURL: '/' }) + .social({ provider: 'google', callbackURL: '/dashboard' }) .then((result) => { if (result?.error) { captureAuthFailure({ diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts index 6d75d13..277b76d 100644 --- a/src/lib/auth-utils.ts +++ b/src/lib/auth-utils.ts @@ -1,10 +1,10 @@ import { headers } from 'next/headers'; -import { auth } from '@/lib/auth'; +import { getAuth } from '@/lib/auth'; import { db } from '@/lib/db'; export async function getCurrentUserId(requestHeaders?: Headers): Promise { - const session = await auth.api.getSession({ headers: requestHeaders ?? (await headers()) }); + const session = await getAuth().api.getSession({ headers: requestHeaders ?? (await headers()) }); const user = session?.user; if (!user?.id) return null; diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 0000000..029946c --- /dev/null +++ b/src/lib/auth.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAuthOptions } from '@/lib/auth'; + +describe('buildAuthOptions', () => { + it('registers Google from runtime credentials', () => { + const options = buildAuthOptions({ + NODE_ENV: 'production', + BETTER_AUTH_SECRET: 'runtime-auth-secret', + BETTER_AUTH_URL: 'https://rolepatch.com', + GOOGLE_CLIENT_ID: 'runtime-client-id', + GOOGLE_CLIENT_SECRET: 'runtime-client-secret', + }); + + expect(options.secret).toBe('runtime-auth-secret'); + expect(options.baseURL).toBe('https://rolepatch.com'); + expect(options.socialProviders).toEqual({ + google: { + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }); + expect(options.trustedOrigins).toEqual(['https://rolepatch.com']); + }); + + it('does not invent production credentials when Google is not configured', () => { + const options = buildAuthOptions({ + NODE_ENV: 'production', + BETTER_AUTH_URL: 'https://rolepatch.com', + }); + + expect(options.secret).toBeUndefined(); + expect(options.socialProviders).toEqual({}); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index efcfdcf..219da87 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,6 @@ import { betterAuth } from 'better-auth'; import { createAdapter } from 'better-auth/adapters'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; import { db } from '@/lib/db'; @@ -202,24 +203,61 @@ const d1Adapter = createAdapter({ }), }); -const canUseLocalAuthSecret = - process.env.NODE_ENV !== 'production' || - process.env.npm_lifecycle_event === 'build' || - process.env.NEXT_PHASE === 'phase-production-build'; - -const authSecret = - process.env.BETTER_AUTH_SECRET?.trim() || - (canUseLocalAuthSecret ? 'resume-tailor-local-development-secret-32-chars' : undefined); -const googleClientId = process.env.GOOGLE_CLIENT_ID?.trim(); -const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim(); - -export const auth = betterAuth({ - secret: authSecret, - baseURL: process.env.BETTER_AUTH_URL, - database: d1Adapter, - socialProviders: - googleClientId && googleClientSecret - ? { google: { clientId: googleClientId, clientSecret: googleClientSecret } } - : {}, - trustedOrigins: [process.env.BETTER_AUTH_URL || ''], -}); +export type AuthRuntimeEnv = { + NODE_ENV?: string; + npm_lifecycle_event?: string; + NEXT_PHASE?: string; + BETTER_AUTH_SECRET?: string; + BETTER_AUTH_URL?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; +}; + +/** + * OpenNext populates process.env from the Cloudflare request environment. + * Keep this construction request-lazy so Worker module evaluation cannot + * freeze production secrets as undefined before that initialization occurs. + */ +export function buildAuthOptions(env: AuthRuntimeEnv = process.env) { + const canUseLocalAuthSecret = + env.NODE_ENV !== 'production' || + env.npm_lifecycle_event === 'build' || + env.NEXT_PHASE === 'phase-production-build'; + const authSecret = + env.BETTER_AUTH_SECRET?.trim() || + (canUseLocalAuthSecret ? 'resume-tailor-local-development-secret-32-chars' : undefined); + const googleClientId = env.GOOGLE_CLIENT_ID?.trim(); + const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim(); + + return { + secret: authSecret, + baseURL: env.BETTER_AUTH_URL?.trim() || undefined, + basePath: '/api/auth', + database: d1Adapter, + socialProviders: + googleClientId && googleClientSecret + ? { google: { clientId: googleClientId, clientSecret: googleClientSecret } } + : {}, + trustedOrigins: env.BETTER_AUTH_URL ? [env.BETTER_AUTH_URL] : [], + }; +} + +function createAuth(env: AuthRuntimeEnv = process.env) { + return betterAuth(buildAuthOptions(env)); +} + +let authInstance: ReturnType | undefined; + +function getRuntimeAuthEnv(): AuthRuntimeEnv { + try { + const { env } = getCloudflareContext({ async: false }); + return env as unknown as AuthRuntimeEnv; + } catch { + return process.env; + } +} + +export function getAuth(): ReturnType { + authInstance ??= createAuth(getRuntimeAuthEnv()); + return authInstance; +} diff --git a/src/lib/db-schema.sql b/src/lib/db-schema.sql index 21c0248..3c39483 100644 --- a/src/lib/db-schema.sql +++ b/src/lib/db-schema.sql @@ -1,3 +1,57 @@ +-- Better Auth tables. Keep these separate from the app-level `users` table: +-- Better Auth uses the singular model names for OAuth identities and sessions. +CREATE TABLE IF NOT EXISTS "user" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL DEFAULT 0, + image TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "session" ( + id TEXT PRIMARY KEY, + expiresAt TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" (userId); + +CREATE TABLE IF NOT EXISTS "account" ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt TEXT, + refreshTokenExpiresAt TEXT, + scope TEXT, + password TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_account_user_id ON "account" (userId); + +CREATE TABLE IF NOT EXISTS "verification" ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" (identifier); + CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, From 1dbb61ea4c2ecdf9d90082f4195f58005f383b1e Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 23 Aug 2026 13:51:41 +0530 Subject: [PATCH 2/2] test(checkout): mock getAuth instead of the removed auth singleton The route now resolves auth per request through getAuth(), so the existing module mock exported a symbol the route no longer imports and vitest failed with: No "getAuth" export is defined on the "@/lib/auth" mock. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/checkout-route.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/checkout-route.test.ts b/__tests__/checkout-route.test.ts index 921a3cd..2a01b14 100644 --- a/__tests__/checkout-route.test.ts +++ b/__tests__/checkout-route.test.ts @@ -13,11 +13,11 @@ vi.mock('next/headers', () => ({ })); vi.mock('@/lib/auth', () => ({ - auth: { + getAuth: () => ({ api: { getSession: (...args: unknown[]) => mocks.getSession(...args), }, - }, + }), })); vi.mock('dodopayments', () => ({