import { TRPCError } from "@trpc/server"; import { eq, and, isNull } from "drizzle-orm"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { db } from "~/server/db"; import { users, accounts } from "~/server/db/schema/auth"; import { hashPassword, verifyPassword } from "~/server/auth/password"; import { createSession } from "~/server/auth/session"; import { signJWT } from "~/server/auth/jwt"; export async function createUserWithPassword( name: string, email: string, password: string, ) { const [existing] = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "Email already in use", }); } const passwordHash = await hashPassword(password); const [user] = await db .insert(users) .values({ name, email, passwordHash }) .returning(); const session = await createSession(user.id); const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); return { user, sessionToken: session.sessionToken, accessToken }; } export async function authenticateUser( email: string, password: string, ) { const [user] = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); if (!user || !user.passwordHash) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password", }); } const valid = await verifyPassword(password, user.passwordHash); if (!valid) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password", }); } const session = await createSession(user.id); const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); return { user, sessionToken: session.sessionToken, accessToken }; } const APPLE_ISSUER = "https://appleid.apple.com"; const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys"); /** * Verifies an Apple identity token and authenticates the user. * If the user does not exist, creates a new account. * If the user exists but has not linked Apple, links the provider. */ export async function authenticateWithApple( identityToken: string, authorizationCode: string, userIdentifier?: string | null, ) { if (!identityToken) { throw new TRPCError({ code: "BAD_REQUEST", message: "Missing identity token", }); } // Verify Apple ID token using Apple's JWKS let payload: { sub: string; email?: string; is_private_email?: string; }; try { const JWKS = createRemoteJWKSet(APPLE_JWKS_URL); const result = await jwtVerify(identityToken, JWKS, { issuer: APPLE_ISSUER, audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant", }); payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; }; } catch (err) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid Apple identity token", }); } const appleUserId = payload.sub; const email = payload.email ?? null; if (!email) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Apple account has no email address", }); } // Check if this Apple account is already linked const [existingAccount] = await db .select() .from(accounts) .where( and( eq(accounts.provider, "apple"), eq(accounts.providerAccountId, appleUserId), ), ) .limit(1); let userId: string; let isNewUser = false; if (existingAccount) { // Already linked — use the existing user userId = existingAccount.userId; isNewUser = false; // Update tokens await db .update(accounts) .set({ accessToken: identityToken, refreshToken: authorizationCode, updatedAt: new Date(), }) .where(eq(accounts.id, existingAccount.id)); } else { // Not linked — check if a user with this email exists const [existingUserByEmail] = await db .select() .from(users) .where(and(eq(users.email, email), isNull(users.deletedAt))) .limit(1); // Apple provides the user's first name and last name only on the initial sign-up // We derive a display name from email if userIdentifier-based lookup doesn't work const displayName = email.split("@")[0] ?? "User"; if (existingUserByEmail) { // Link Apple to existing user userId = existingUserByEmail.id; isNewUser = false; await db.insert(accounts).values({ userId, provider: "apple", providerAccountId: appleUserId, accessToken: identityToken, refreshToken: authorizationCode, }); } else { // Create new user with Apple isNewUser = true; const [newUser] = await db .insert(users) .values({ name: displayName, email, emailVerified: new Date(), }) .returning(); userId = newUser.id; await db.insert(accounts).values({ userId, provider: "apple", providerAccountId: appleUserId, accessToken: identityToken, refreshToken: authorizationCode, }); } } // Create session and JWT const session = await createSession(userId); const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" }); } return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser }; } /** * Refreshes an access token using a valid refresh token. */ export async function refreshAccessToken(refreshToken: string) { const { verifyJWT, signJWT } = await import("~/server/auth/jwt"); let payload: { sub?: string; type?: string }; try { payload = await verifyJWT<{ sub: string; type: string }>(refreshToken); } catch { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid or expired refresh token", }); } if (payload.type !== "refresh") { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token type", }); } const userId = payload.sub!; const [user] = await db .select() .from(users) .where(and(eq(users.id, userId), isNull(users.deletedAt))) .limit(1); if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found", }); } const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } /** * Sends a password reset email. */ export async function forgotPassword(email: string) { const [user] = await db .select() .from(users) .where(and(eq(users.email, email), isNull(users.deletedAt))) .limit(1); if (!user) { // Don't reveal whether the email exists return { success: true }; } // Generate a reset token (valid for 1 hour) const resetToken = await signJWT( { sub: user.id, type: "password-reset" }, { expiresIn: "1h" }, ); // In production, send via email service (Resend, SendGrid, etc.) // For now, we log it and return success console.log(`Password reset token for ${email}: ${resetToken}`); // TODO: Send email via Resend // const { Resend } = await import("resend"); // const resend = new Resend(process.env.RESEND_API_KEY); // await resend.emails.send({ // from: "Kordant ", // to: email, // subject: "Reset your password", // html: `Reset password`, // }); return { success: true }; } /** * Resets a user's password using a valid reset token. */ export async function resetPassword(token: string, newPassword: string) { const { verifyJWT } = await import("~/server/auth/jwt"); let payload: { sub?: string; type?: string }; try { payload = await verifyJWT<{ sub: string; type: string }>(token); } catch { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid or expired reset token", }); } if (payload.type !== "password-reset") { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid token type", }); } const userId = payload.sub!; const passwordHash = await hashPassword(newPassword); await db .update(users) .set({ passwordHash, updatedAt: new Date() }) .where(eq(users.id, userId)); return { success: true }; } /** * Revokes all sessions for a user (logout everywhere). */ export async function revokeUserSessions(userId: string) { const { sessions } = await import("~/server/db/schema/auth"); await db .delete(sessions) .where(eq(sessions.userId, userId)); return { success: true }; } export async function getUserById(id: string) { const user = await db.query.users.findFirst({ where: eq(users.id, id), with: { accounts: true, sessions: true, deviceTokens: true, familyGroups: true, familyGroupOwned: true, subscriptions: true, }, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } return user; } export async function updateUser( id: string, data: { name?: string; email?: string; image?: string }, ) { const [existing] = await db .select() .from(users) .where(eq(users.id, id)) .limit(1); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } if (data.email && data.email !== existing.email) { const [duplicate] = await db .select() .from(users) .where(eq(users.email, data.email)) .limit(1); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: "Email already in use", }); } } const [updated] = await db .update(users) .set(data) .where(eq(users.id, id)) .returning(); return updated; } export async function deleteUser(id: string) { const [existing] = await db .select() .from(users) .where(eq(users.id, id)) .limit(1); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } const [deleted] = await db .update(users) .set({ deletedAt: new Date() }) .where(eq(users.id, id)) .returning(); return deleted; }