Files
Kordant/web/src/server/services/user.service.ts

409 lines
10 KiB
TypeScript

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 <support@kordant.com>",
// to: email,
// subject: "Reset your password",
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
// });
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;
}