diff --git a/packages/db/package.json b/packages/db/package.json index a7efdff4..a13419c0 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -17,6 +17,7 @@ "db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts", "migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts", "db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts", + "db:login-link": "tsx src/scripts/generate-login-link.ts", "db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts", "move-users": "tsx src/scripts/supabase-to-betterauth.ts", "pull": "drizzle-kit pull --config drizzle.config.ts", diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 43a0cf04..4d437840 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -9,6 +9,7 @@ import { createProfileHook } from './auth/hooks/create-profile'; import { db } from '@db/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { config as emailAndPassword } from './auth/email-password'; +import { loginLink } from './auth/plugins/login-link'; import { sso } from '@better-auth/sso'; import { syncUserWithProfile } from './auth/hooks/sync-user'; import { tokenExchange } from './auth/plugins/token-exchange'; @@ -83,6 +84,7 @@ export const auth: ReturnType = betterAuth({ // OIDC providers are registered dynamically per organization // via the admin API (auth.api.registerSSOProvider) }), + loginLink(), tokenExchange() ] }); diff --git a/packages/db/src/auth/plugins/login-link.ts b/packages/db/src/auth/plugins/login-link.ts new file mode 100644 index 00000000..9edf1fb7 --- /dev/null +++ b/packages/db/src/auth/plugins/login-link.ts @@ -0,0 +1,119 @@ +import * as schema from '@db/schema'; + +import { createAuthEndpoint } from 'better-auth/api'; +import { setSessionCookie as _setSessionCookie } from 'better-auth/cookies'; +const setSessionCookie = _setSessionCookie as ( + ctx: unknown, + data: { session: Record; user: Record } +) => Promise; + +import { db } from '@db/drizzle'; +import { eq } from 'drizzle-orm'; +import { jwtVerify } from 'jose'; +import { ZLoginLinkPayload, ZLoginLinkQuery } from '@cio/utils/validation/organization'; + +const SESSION_EXPIRES_IN_SEC = 60 * 60 * 24 * 7; + +function generateToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +function getLoginLinkSecret(): Uint8Array { + const secret = process.env.BETTER_AUTH_SECRET?.trim(); + + if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required for login links'); + } + + return new TextEncoder().encode(secret); +} + +export function loginLink() { + return { + id: 'login-link', + endpoints: { + loginLink: createAuthEndpoint('/login-link', { method: 'GET' }, async (c) => { + const url = new URL(c.request?.url ?? '', 'http://localhost'); + const query = ZLoginLinkQuery.safeParse({ + token: url.searchParams.get('token') ?? '', + redirect: url.searchParams.get('redirect') ?? undefined + }); + + if (!query.success) { + return c.json({ error: 'Invalid query', message: query.error.message }, { status: 400 }); + } + + const redirectUrl = query.data.redirect && query.data.redirect.startsWith('/') ? query.data.redirect : '/'; + + let payload: unknown; + try { + const verified = await jwtVerify(query.data.token, getLoginLinkSecret(), { + algorithms: ['HS256'] + }); + payload = verified.payload; + } catch { + return c.json({ error: 'Invalid or expired login link' }, { status: 401 }); + } + + const parsedPayload = ZLoginLinkPayload.safeParse(payload); + if (!parsedPayload.success) { + return c.json({ error: 'Invalid login link payload' }, { status: 400 }); + } + + const normalizedEmail = parsedPayload.data.email.toLowerCase().trim(); + const [user] = await db.select().from(schema.user).where(eq(schema.user.id, parsedPayload.data.sub)).limit(1); + + if (!user || user.email.toLowerCase().trim() !== normalizedEmail) { + return c.json({ error: 'User not found' }, { status: 404 }); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_EXPIRES_IN_SEC * 1000); + const sessionToken = generateToken(); + + const [sessionRow] = await db + .insert(schema.session) + .values({ + userId: user.id, + token: sessionToken, + expiresAt, + createdAt: now, + updatedAt: now, + impersonatedBy: 'login-link' + }) + .returning(); + + if (!sessionRow) { + return c.json({ error: 'Failed to create session' }, { status: 500 }); + } + + const session = { + id: sessionRow.id, + userId: sessionRow.userId, + token: sessionRow.token, + expiresAt: sessionRow.expiresAt, + createdAt: sessionRow.createdAt, + updatedAt: sessionRow.updatedAt, + ipAddress: sessionRow.ipAddress ?? undefined, + userAgent: sessionRow.userAgent ?? undefined + }; + + const userPayload = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image ?? null, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }; + + await setSessionCookie(c, { session, user: userPayload }); + + return c.redirect(redirectUrl); + }) + } + }; +} diff --git a/packages/db/src/scripts/generate-login-link.ts b/packages/db/src/scripts/generate-login-link.ts new file mode 100644 index 00000000..c32b3bb9 --- /dev/null +++ b/packages/db/src/scripts/generate-login-link.ts @@ -0,0 +1,68 @@ +import 'dotenv/config'; + +import * as schema from '../schema'; + +import { eq } from 'drizzle-orm'; +import { SignJWT } from 'jose'; +import { db } from '../drizzle'; + +const LOGIN_LINK_TTL_MINUTES = 10; + +function getBaseUrl(): string { + return process.env.PUBLIC_SERVER_URL?.trim() || 'http://localhost:3002'; +} + +function getSecret(): Uint8Array { + const secret = process.env.BETTER_AUTH_SECRET?.trim(); + + if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required'); + } + + return new TextEncoder().encode(secret); +} + +function getArgs() { + const email = process.argv[2]?.trim().toLowerCase(); + const redirect = process.argv[3]?.trim() || '/'; + + if (!email) { + throw new Error('Usage: pnpm --filter @cio/db db:login-link [redirect-path]'); + } + + return { email, redirect }; +} + +async function main() { + const { email, redirect } = getArgs(); + + const [user] = await db.select().from(schema.user).where(eq(schema.user.email, email)).limit(1); + + if (!user) { + throw new Error(`User not found for email: ${email}`); + } + + const token = await new SignJWT({ + email, + type: 'login-link' + }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(user.id) + .setIssuedAt() + .setExpirationTime(`${LOGIN_LINK_TTL_MINUTES}m`) + .sign(getSecret()); + + const url = new URL('/api/auth/login-link', getBaseUrl()); + url.searchParams.set('token', token); + + if (redirect.startsWith('/')) { + url.searchParams.set('redirect', redirect); + } + + console.log(url.toString()); +} + +main().catch((error) => { + console.error('generate-login-link error:', error instanceof Error ? error.message : error); + process.exit(1); +});