summaryrefslogtreecommitdiff
path: root/src/routes
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-04-06 14:25:51 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-04-06 14:25:51 +0200
commit18efc1e708821fd1f36532d659b928c320f4a143 (patch)
tree2938c5fcefb4faccb2bfa01e59eaefc8efd409f7 /src/routes
parentde366e1607ae66fa5227ed8a5d7b0d2a2996e002 (diff)
feat: google signin
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/signin.ts308
1 files changed, 303 insertions, 5 deletions
diff --git a/src/routes/signin.ts b/src/routes/signin.ts
index 2060596..50b03e3 100644
--- a/src/routes/signin.ts
+++ b/src/routes/signin.ts
@@ -8,6 +8,8 @@ import { eq } from "drizzle-orm";
import { logger } from "../lib/pino";
import * as jose from "jose";
import { getTransporter } from "../email/email";
+import { verifyGoogleIdToken } from "../account/google";
+import { randomUUID } from "node:crypto";
/** Validates signin request body with email and password */
const SigninBodySchema = z.object({
@@ -31,7 +33,6 @@ const ResetPasswordRequestSchema = z.object({
.email({ error: "Invalid email" })
.nonempty({ error: "Email can't be empty" }),
),
- link: z.url({ error: "Invalid link" }),
});
const ResetPasswordConfirmSchema = z.object({
@@ -39,7 +40,18 @@ const ResetPasswordConfirmSchema = z.object({
password: z.string().min(1, { error: "Password can't be empty" }),
});
+const GoogleSigninBodySchema = z.object({
+ idToken: z.string().min(1, { error: "Google ID token can't be empty" }),
+});
+
const router: express.Router = express.Router();
+router.use(express.urlencoded({ extended: true }));
+
+function generate6DigitCode(): string {
+ return Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0");
+}
function getResetPasswordSecret(): Uint8Array {
const secret = process.env.RESET_PASSWORD_JWT_SECRET;
@@ -75,11 +87,122 @@ async function verifyResetPasswordToken(token: string): Promise<number> {
return payload.id;
}
+function getResetPasswordBaseUrl(): string {
+ const baseUrl = process.env.BASE_URL;
+
+ if (!baseUrl) {
+ logger.error("BASE_URL environment variable not set");
+ throw new Error("BASE_URL not configured");
+ }
+
+ return baseUrl.replace(/\/+$/, "");
+}
+
+function renderResetPasswordPage(
+ token: string,
+ options?: { error?: string; success?: string },
+): string {
+ const title = options?.success ? "Password Updated" : "Reset Password";
+ const message = options?.success
+ ? "Your Buddy password was updated. You can return to the app and sign in with your new password."
+ : "Enter a new password for your Buddy account.";
+
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>${title}</title>
+ <style>
+ :root {
+ color-scheme: light;
+ font-family: Arial, Helvetica, sans-serif;
+ }
+ body {
+ margin: 0;
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ background: linear-gradient(180deg, #fff5f5 0%, #ffe3e3 100%);
+ color: #1f2937;
+ }
+ main {
+ width: min(92vw, 420px);
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 32px 24px;
+ box-shadow: 0 18px 40px rgba(244, 46, 46, 0.16);
+ }
+ h1 {
+ margin: 0 0 12px;
+ font-size: 28px;
+ }
+ p {
+ margin: 0 0 20px;
+ line-height: 1.5;
+ }
+ form {
+ display: grid;
+ gap: 12px;
+ }
+ input {
+ width: 100%;
+ box-sizing: border-box;
+ border: 1px solid #d1d5db;
+ border-radius: 12px;
+ padding: 14px 16px;
+ font-size: 16px;
+ }
+ button {
+ border: 0;
+ border-radius: 12px;
+ padding: 14px 16px;
+ font-size: 16px;
+ font-weight: 700;
+ background: #f42e2e;
+ color: white;
+ cursor: pointer;
+ }
+ .error {
+ margin-bottom: 16px;
+ color: #b91c1c;
+ background: #fee2e2;
+ padding: 12px 14px;
+ border-radius: 12px;
+ }
+ .success {
+ margin-bottom: 16px;
+ color: #166534;
+ background: #dcfce7;
+ padding: 12px 14px;
+ border-radius: 12px;
+ }
+ </style>
+</head>
+<body>
+ <main>
+ <h1>${title}</h1>
+ <p>${message}</p>
+ ${options?.error ? `<div class="error">${options.error}</div>` : ""}
+ ${options?.success ? `<div class="success">${options.success}</div>` : ""}
+ ${
+ options?.success
+ ? ""
+ : `<form method="post" action="/reset-password/${encodeURIComponent(token)}">
+ <input type="password" name="password" placeholder="New password" required />
+ <button type="submit">Update password</button>
+ </form>`
+ }
+ </main>
+</body>
+</html>`;
+}
+
router.post("/resetpassword", async (req, res) => {
const requestResetParsed = ResetPasswordRequestSchema.safeParse(req.body);
if (requestResetParsed.success) {
- const { email, link } = requestResetParsed.data;
+ const { email } = requestResetParsed.data;
logger.info({ email }, "Password reset requested");
const existingUser = (
@@ -96,8 +219,7 @@ router.post("/resetpassword", async (req, res) => {
try {
const token = await signResetPasswordToken(existingUser.id);
- const separator = link.includes("?") ? "&" : "?";
- const resetLink = `${link}${separator}token=${encodeURIComponent(token)}`;
+ const resetLink = `${getResetPasswordBaseUrl()}/reset-password/${encodeURIComponent(token)}`;
await getTransporter().sendMail({
from: `"Buddy 🐶" <${process.env.SMTP_EMAIL}>`,
@@ -138,7 +260,9 @@ router.post("/resetpassword", async (req, res) => {
}
try {
- const userId = await verifyResetPasswordToken(confirmResetParsed.data.token);
+ const userId = await verifyResetPasswordToken(
+ confirmResetParsed.data.token,
+ );
const hashedPassword = await argon2.hash(confirmResetParsed.data.password);
const updatedUsers = await db
@@ -169,6 +293,87 @@ router.post("/resetpassword", async (req, res) => {
}
});
+router.get("/reset-password/:token", async (req, res) => {
+ const { token } = req.params;
+
+ try {
+ await verifyResetPasswordToken(token);
+ return res.status(200).type("html").send(renderResetPasswordPage(token));
+ } catch (error) {
+ logger.warn({ error }, "Reset password page opened with invalid token");
+ return res
+ .status(400)
+ .type("html")
+ .send(
+ renderResetPasswordPage(token, {
+ error: "This reset link is invalid or has expired.",
+ }),
+ );
+ }
+});
+
+router.post("/reset-password/:token", async (req, res) => {
+ const { token } = req.params;
+ const parsed = z
+ .object({
+ password: z.string().min(1, { error: "Password can't be empty" }),
+ })
+ .safeParse(req.body);
+
+ if (!parsed.success) {
+ return res
+ .status(400)
+ .type("html")
+ .send(
+ renderResetPasswordPage(token, {
+ error: "Please enter a new password.",
+ }),
+ );
+ }
+
+ try {
+ const userId = await verifyResetPasswordToken(token);
+ const hashedPassword = await argon2.hash(parsed.data.password);
+
+ const updatedUsers = await db
+ .update(users)
+ .set({ password: hashedPassword })
+ .where(eq(users.id, userId))
+ .returning({ id: users.id });
+
+ if (updatedUsers.length === 0) {
+ return res
+ .status(400)
+ .type("html")
+ .send(
+ renderResetPasswordPage(token, {
+ error: "This reset link is invalid or has expired.",
+ }),
+ );
+ }
+
+ logger.info({ userId }, "Password reset completed from hosted form");
+ return res
+ .status(200)
+ .type("html")
+ .send(
+ renderResetPasswordPage(token, {
+ success: "Your password has been updated successfully.",
+ }),
+ );
+ } catch (error) {
+ logger.warn({ error }, "Hosted password reset failed");
+ return res
+ .status(400)
+ .type("html")
+ .send(
+ renderResetPasswordPage(token, {
+ error: "This reset link is invalid or has expired.",
+ }),
+ );
+ }
+});
+
router.post("/signin", async (req, res) => {
const body: Infer<typeof SigninBodySchema> = req.body;
logger.info({ email: body.email }, "Signin attempt initiated");
@@ -235,6 +440,99 @@ router.post("/signin", async (req, res) => {
});
});
+router.post("/signin/google", async (req, res) => {
+ const parsed = GoogleSigninBodySchema.safeParse(req.body);
+
+ if (!parsed.success) {
+ logger.warn({ error: parsed.error }, "Google signin validation failed");
+ return res.send({
+ success: false,
+ reason: parsed.error,
+ token: "",
+ });
+ }
+
+ try {
+ const googleProfile = await verifyGoogleIdToken(parsed.data.idToken);
+
+ if (!googleProfile.emailVerified) {
+ logger.warn(
+ { email: googleProfile.email, subject: googleProfile.subject },
+ "Google signin rejected: email not verified by Google",
+ );
+ return res.send({
+ success: false,
+ reason: "Google account email is not verified",
+ token: "",
+ });
+ }
+
+ let existingUser = (
+ await db
+ .select()
+ .from(users)
+ .where(eq(users.email, googleProfile.email))
+ .limit(1)
+ )[0];
+
+ if (!existingUser) {
+ const placeholderPassword = await argon2.hash(randomUUID());
+ const insertedUsers = await db
+ .insert(users)
+ .values({
+ email: googleProfile.email,
+ password: placeholderPassword,
+ emailVerified: true,
+ emailCode: generate6DigitCode(),
+ })
+ .returning();
+
+ existingUser = insertedUsers[0];
+
+ logger.info(
+ { userId: existingUser!.id, email: googleProfile.email },
+ "Created new user from Google signin",
+ );
+ } else if (!existingUser.emailVerified) {
+ const updatedUsers = await db
+ .update(users)
+ .set({ emailVerified: true })
+ .where(eq(users.id, existingUser.id))
+ .returning();
+
+ existingUser = updatedUsers[0];
+
+ logger.info(
+ { userId: existingUser!.id, email: googleProfile.email },
+ "Marked existing user as verified from Google signin",
+ );
+ }
+
+ const jwt = await signJwt(
+ { id: existingUser!.id, type: "parent" },
+ "urn:buddy:users",
+ );
+
+ logger.info(
+ { userId: existingUser!.id, email: googleProfile.email },
+ "Google signin successful",
+ );
+
+ return res.send({
+ success: true,
+ token: jwt,
+ reason: "",
+ });
+ } catch (error) {
+ logger.warn({ error }, "Google signin failed");
+ return res.send({
+ success: false,
+ reason: "Invalid Google token",
+ token: "",
+ });
+ }
+});
+
router.post("/kid/link", async (req, res) => {
const body: Infer<typeof SigninBodySchema> = req.body;