summaryrefslogtreecommitdiff
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
parentde366e1607ae66fa5227ed8a5d7b0d2a2996e002 (diff)
feat: google signin
-rw-r--r--src/account/google.ts42
-rw-r--r--src/routes/signin.ts308
-rw-r--r--test/signin-routes.test.ts177
3 files changed, 506 insertions, 21 deletions
diff --git a/src/account/google.ts b/src/account/google.ts
new file mode 100644
index 0000000..2194a18
--- /dev/null
+++ b/src/account/google.ts
@@ -0,0 +1,42 @@
+import * as jose from "jose";
+import { logger } from "../lib/pino";
+
+const googleJwks = jose.createRemoteJWKSet(
+ new URL("https://www.googleapis.com/oauth2/v3/certs"),
+);
+
+export interface GoogleUserProfile {
+ email: string;
+ emailVerified: boolean;
+ subject: string;
+}
+
+export async function verifyGoogleIdToken(
+ token: string,
+): Promise<GoogleUserProfile> {
+ const googleClientId = process.env.GOOGLE_CLIENT_ID;
+
+ if (!googleClientId) {
+ logger.error("GOOGLE_CLIENT_ID environment variable not set");
+ throw new Error("GOOGLE_CLIENT_ID not configured");
+ }
+
+ const { payload } = await jose.jwtVerify(token, googleJwks, {
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
+ audience: googleClientId,
+ });
+
+ if (typeof payload.email !== "string" || payload.email.length === 0) {
+ throw new Error("Google token is missing email");
+ }
+
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) {
+ throw new Error("Google token is missing subject");
+ }
+
+ return {
+ email: payload.email,
+ emailVerified: payload.email_verified === true,
+ subject: payload.sub,
+ };
+}
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;
diff --git a/test/signin-routes.test.ts b/test/signin-routes.test.ts
index 98d1153..74f205d 100644
--- a/test/signin-routes.test.ts
+++ b/test/signin-routes.test.ts
@@ -6,6 +6,8 @@ import { users } from "../src/db/schema";
import { eq } from "drizzle-orm";
import signinRouter from "../src/routes/signin";
import type { Request, Response } from "express";
+import { verifyGoogleIdToken } from "../src/account/google";
+import { signJwt } from "../src/account/jwt";
const sendMail = vi.fn();
@@ -15,24 +17,47 @@ vi.mock("../src/email/email", () => ({
}),
}));
+vi.mock("../src/account/google", () => ({
+ verifyGoogleIdToken: vi.fn(),
+}));
+
+vi.mock("../src/account/jwt", async () => {
+ const actual = await vi.importActual<typeof import("../src/account/jwt")>(
+ "../src/account/jwt",
+ );
+
+ return {
+ ...actual,
+ signJwt: vi.fn(),
+ };
+});
+
describe("Signin Routes", () => {
beforeEach(() => {
process.env.RESET_PASSWORD_JWT_SECRET = "test-reset-secret";
process.env.SMTP_EMAIL = "buddy@example.com";
+ process.env.BASE_URL = "https://buddy.example";
sendMail.mockReset();
+ vi.mocked(verifyGoogleIdToken).mockReset();
+ vi.mocked(signJwt).mockReset();
+ vi.mocked(signJwt).mockResolvedValue("signed-jwt");
});
- function getRouteHandler(path: string) {
+ function getRouteHandler(path: string, method: "get" | "post") {
const layer = (
signinRouter as unknown as {
stack: Array<{
route?: {
path: string;
+ methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
}
- ).stack.find((candidate) => candidate.route?.path === path);
+ ).stack.find(
+ (candidate) =>
+ candidate.route?.path === path && candidate.route.methods[method],
+ );
if (!layer?.route?.stack[0]) {
throw new Error(`Route handler for ${path} not found`);
@@ -41,21 +66,37 @@ describe("Signin Routes", () => {
return layer.route.stack[0].handle;
}
- async function invokeRoute(body: unknown) {
- const handler = getRouteHandler("/resetpassword");
- const req = { body } as Request;
+ async function invokeRoute(
+ path: string,
+ method: "get" | "post",
+ options?: { body?: unknown; params?: Record<string, string> },
+ ) {
+ const handler = getRouteHandler(path, method);
+ const req = {
+ body: options?.body,
+ params: options?.params ?? {},
+ } as Request;
const res = {
statusCode: 200,
body: undefined as unknown,
+ responseType: undefined as string | undefined,
status(code: number) {
this.statusCode = code;
return this;
},
+ type(value: string) {
+ this.responseType = value;
+ return this;
+ },
send(payload: unknown) {
this.body = payload;
return this;
},
- } as Response & { statusCode: number; body: unknown };
+ } as Response & {
+ statusCode: number;
+ body: unknown;
+ responseType?: string;
+ };
await handler(req, res);
@@ -63,9 +104,10 @@ describe("Signin Routes", () => {
}
test("should send a password reset email with a signed reset link", async () => {
- const response = await invokeRoute({
- email: "test@example.com",
- link: "https://buddy.example/reset-password",
+ const response = await invokeRoute("/resetpassword", "post", {
+ body: {
+ email: "test@example.com",
+ },
});
expect(response.statusCode).toBe(200);
@@ -83,7 +125,10 @@ describe("Signin Routes", () => {
"Your Buddy password reset link is ",
"",
);
- const token = new URL(sentLink).searchParams.get("token");
+ expect(sentLink.startsWith("https://buddy.example/reset-password/")).toBe(
+ true,
+ );
+ const token = sentLink.split("/").pop();
expect(token).toBeTruthy();
@@ -109,24 +154,124 @@ describe("Signin Routes", () => {
.setExpirationTime("1h")
.sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!));
- const response = await invokeRoute({
- token,
- password: "new-password",
+ const response = await invokeRoute("/reset-password/:token", "post", {
+ params: { token },
+ body: {
+ password: "new-password",
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.responseType).toBe("html");
+ expect(String(response.body)).toContain(
+ "Your password has been updated successfully.",
+ );
+
+ const updatedUser = (
+ await db.select().from(users).where(eq(users.id, 1)).limit(1)
+ )[0];
+
+ expect(updatedUser).toBeTruthy();
+ expect(await argon2.verify(updatedUser!.password, "new-password")).toBe(
+ true,
+ );
+ });
+
+ test("should create a new verified user from a valid Google login", async () => {
+ vi.mocked(verifyGoogleIdToken).mockResolvedValue({
+ email: "google-user@example.com",
+ emailVerified: true,
+ subject: "google-subject-1",
+ });
+
+ const response = await invokeRoute("/signin/google", "post", {
+ body: {
+ idToken: "google-id-token",
+ },
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
success: true,
+ token: "signed-jwt",
+ reason: "",
+ });
+
+ const insertedUser = (
+ await db
+ .select()
+ .from(users)
+ .where(eq(users.email, "google-user@example.com"))
+ .limit(1)
+ )[0];
+
+ expect(insertedUser).toBeTruthy();
+ expect(insertedUser!.emailVerified).toBe(true);
+ expect(insertedUser!.emailCode).toHaveLength(6);
+ expect(await argon2.verify(insertedUser!.password, "google-id-token")).toBe(
+ false,
+ );
+ expect(signJwt).toHaveBeenCalledWith(
+ { id: insertedUser!.id, type: "parent" },
+ "urn:buddy:users",
+ );
+ });
+
+ test("should log in an existing user with Google and mark it verified", async () => {
+ await db
+ .update(users)
+ .set({ emailVerified: false })
+ .where(eq(users.email, "test@example.com"));
+
+ vi.mocked(verifyGoogleIdToken).mockResolvedValue({
+ email: "test@example.com",
+ emailVerified: true,
+ subject: "google-subject-2",
+ });
+
+ const response = await invokeRoute("/signin/google", "post", {
+ body: {
+ idToken: "google-id-token",
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toEqual({
+ success: true,
+ token: "signed-jwt",
reason: "",
});
const updatedUser = (
- await db.select().from(users).where(eq(users.id, 1)).limit(1)
+ await db.select().from(users).where(eq(users.email, "test@example.com"))
)[0];
expect(updatedUser).toBeTruthy();
- expect(await argon2.verify(updatedUser!.password, "new-password")).toBe(
- true,
+ expect(updatedUser!.emailVerified).toBe(true);
+ expect(signJwt).toHaveBeenCalledWith(
+ { id: updatedUser!.id, type: "parent" },
+ "urn:buddy:users",
+ );
+ });
+
+ test("should render the hosted reset password form for a valid token", async () => {
+ const token = await new jose.SignJWT({ id: 1, type: "password_reset" })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setIssuer("urn:lajosh:buddy")
+ .setAudience("urn:buddy:password-reset")
+ .setExpirationTime("1h")
+ .sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!));
+
+ const response = await invokeRoute("/reset-password/:token", "get", {
+ params: { token },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.responseType).toBe("html");
+ expect(String(response.body)).toContain("Reset Password");
+ expect(String(response.body)).toContain(
+ `action="/reset-password/${encodeURIComponent(token)}"`,
);
});
});