409 lines
10 KiB
TypeScript
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;
|
|
}
|