From 18efc1e708821fd1f36532d659b928c320f4a143 Mon Sep 17 00:00:00 2001 From: JustZvan Date: Mon, 6 Apr 2026 14:25:51 +0200 Subject: feat: google signin --- src/account/google.ts | 42 +++++++ src/routes/signin.ts | 308 ++++++++++++++++++++++++++++++++++++++++++++- test/signin-routes.test.ts | 177 +++++++++++++++++++++++--- 3 files changed, 506 insertions(+), 21 deletions(-) create mode 100644 src/account/google.ts 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 { + 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 { 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 ` + + + + + ${title} + + + +
+

${title}

+

${message}

+ ${options?.error ? `
${options.error}
` : ""} + ${options?.success ? `
${options.success}
` : ""} + ${ + options?.success + ? "" + : `
+ + +
` + } +
+ +`; +} + 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 = 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 = 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( + "../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; 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 }, + ) { + 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)}"`, ); }); }); -- cgit v1.2.3