Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions __tests__/checkout-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ vi.mock('next/headers', () => ({
}));

vi.mock('@/lib/auth', () => ({
auth: {
getAuth: () => ({
api: {
getSession: (...args: unknown[]) => mocks.getSession(...args),
},
},
}),
}));

vi.mock('dodopayments', () => ({
Expand Down
54 changes: 54 additions & 0 deletions migrations/0002_better_auth_tables.sql
Original file line number Diff line number Diff line change
@@ -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);
39 changes: 39 additions & 0 deletions src/__tests__/user-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UserMenu />);

await user.click(screen.getByRole('button', { name: 'Sign in' }));

expect(mockSignInSocial).toHaveBeenCalledWith({
provider: 'google',
callbackURL: '/dashboard',
});
});
});
4 changes: 2 additions & 2 deletions src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -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));
4 changes: 2 additions & 2 deletions src/app/api/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions src/lib/auth-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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;

Expand Down
35 changes: 35 additions & 0 deletions src/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
80 changes: 59 additions & 21 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<typeof createAuth> | undefined;

function getRuntimeAuthEnv(): AuthRuntimeEnv {
try {
const { env } = getCloudflareContext({ async: false });
return env as unknown as AuthRuntimeEnv;
} catch {
return process.env;
}
}

export function getAuth(): ReturnType<typeof createAuth> {
authInstance ??= createAuth(getRuntimeAuthEnv());
return authInstance;
}
54 changes: 54 additions & 0 deletions src/lib/db-schema.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading