From c170bc1ae0e03ad8be63be70f82310833dcc7d12 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 10:56:22 +0530 Subject: [PATCH 01/19] feat(auth): establish server-trusted Google registration consent --- src/lib/google-signup-consent.ts | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/lib/google-signup-consent.ts diff --git a/src/lib/google-signup-consent.ts b/src/lib/google-signup-consent.ts new file mode 100644 index 00000000..175e4a96 --- /dev/null +++ b/src/lib/google-signup-consent.ts @@ -0,0 +1,37 @@ +import { TERMS_VERSION } from "@/content/legal"; + +// A Google registration is explicitly requested, with the same legal consent as email signup. +// This data is promoted into Better Auth's signed, server-only OAuth state, never accepted +// directly from provider profile fields or a callback query string. +export function googleSignupServerContext(additionalData: unknown, now = new Date()) { + if ( + !additionalData || + typeof additionalData !== "object" || + Array.isArray(additionalData) || + (additionalData as Record).acceptTerms !== true || + (additionalData as Record).termsVersion !== TERMS_VERSION + ) + return null; + return { + googleSignupTermsVersion: TERMS_VERSION, + googleSignupAcceptedAt: now.toISOString(), + }; +} + +export function googleSignupConsentFromState(state: unknown, now = Date.now()) { + if (!state || typeof state !== "object" || Array.isArray(state)) return null; + const serverContext = (state as Record).serverContext; + if (!serverContext || typeof serverContext !== "object" || Array.isArray(serverContext)) + return null; + const consent = serverContext as Record; + if ( + consent.googleSignupTermsVersion !== TERMS_VERSION || + typeof consent.googleSignupAcceptedAt !== "string" + ) + return null; + const acceptedAt = Date.parse(consent.googleSignupAcceptedAt); + // Never trust a client-provided timestamp, stale OAuth state or future acceptance. + if (!Number.isFinite(acceptedAt) || acceptedAt > now || now - acceptedAt > 15 * 60 * 1000) + return null; + return { termsVersion: TERMS_VERSION, termsAcceptedAt: new Date(acceptedAt) }; +} From 3b4976e2e7f8defd6a61052465f6eaf35b3ce11a Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 10:56:25 +0530 Subject: [PATCH 02/19] feat(auth): establish server-trusted Google registration consent --- tests/google-signup-consent.test.ts | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/google-signup-consent.test.ts diff --git a/tests/google-signup-consent.test.ts b/tests/google-signup-consent.test.ts new file mode 100644 index 00000000..7f91de39 --- /dev/null +++ b/tests/google-signup-consent.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { TERMS_VERSION } from "../src/content/legal"; +import { + googleSignupConsentFromState, + googleSignupServerContext, +} from "../src/lib/google-signup-consent"; + +describe("explicit Google registration legal consent", () => { + const now = new Date("2026-11-11T04:30:00.000Z"); + it("only stamps the current policy after affirmative consent", () => { + expect(googleSignupServerContext(undefined, now)).toBeNull(); + expect(googleSignupServerContext({ acceptTerms: "true", termsVersion: TERMS_VERSION }, now)).toBeNull(); + expect(googleSignupServerContext({ acceptTerms: true, termsVersion: "old" }, now)).toBeNull(); + expect(googleSignupServerContext({ acceptTerms: true, termsVersion: TERMS_VERSION }, now)).toEqual({ + googleSignupTermsVersion: TERMS_VERSION, + googleSignupAcceptedAt: now.toISOString(), + }); + }); + it("accepts only fresh server-side OAuth state", () => { + const context = googleSignupServerContext({ acceptTerms: true, termsVersion: TERMS_VERSION }, now)!; + const state = { serverContext: context }; + expect(googleSignupConsentFromState(state, now.getTime() + 60_000)).toEqual({ + termsVersion: TERMS_VERSION, + termsAcceptedAt: now, + }); + expect(googleSignupConsentFromState({ additionalData: context }, now.getTime())).toBeNull(); + expect(googleSignupConsentFromState({ serverContext: { ...context, googleSignupTermsVersion: "old" } }, now.getTime())).toBeNull(); + expect(googleSignupConsentFromState(state, now.getTime() + 15 * 60_000 + 1)).toBeNull(); + expect(googleSignupConsentFromState(state, now.getTime() - 1)).toBeNull(); + expect(googleSignupConsentFromState({ serverContext: { ...context, googleSignupAcceptedAt: "not-a-date" } }, now.getTime())).toBeNull(); + }); +}); From fe460e27e669350031b9a67c7759650bbc2e0aa0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 10:56:27 +0530 Subject: [PATCH 03/19] feat(auth): establish server-trusted Google registration consent --- .../android-release/google-auth-acceptance.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/android-release/google-auth-acceptance.md diff --git a/docs/android-release/google-auth-acceptance.md b/docs/android-release/google-auth-acceptance.md new file mode 100644 index 00000000..ec55e3a0 --- /dev/null +++ b/docs/android-release/google-auth-acceptance.md @@ -0,0 +1,44 @@ +# Android / web Google authentication acceptance + +MenuMinute's Play app is an Android Trusted Web Activity (Chrome), not a WebView. +The registered Google OAuth client is a **Web application** pointing at +`https://food.bababuilds.com/api/auth/callback/google`. Do not use an Android-only +OAuth redirect, and do not publish Google client secrets in app resources. + +## Supported journeys + +- Existing account, already linked to Google: sign in from the first screen; return to the + validated `next` path (the TWA uses `/app`). +- Existing email/password-only account: do **not** silently link by matching email. + Authenticate with password first, then explicitly link under Account Security. +- New owner: first-screen Create free account -> registration, check current Terms/Privacy + acceptance -> Continue with Google -> Google identity -> verified session -> first outlet + onboarding. A password is not required for a Google-created account. +- New owner via email/password: the existing signup, deferred verification and onboarding + remain available. +- A user protected by TOTP cannot bypass the authenticator with Google. Continue with + email/password and TOTP instead. + +The backend requires `requestSignUp` and signed server-side OAuth consent state before +creating an account through Google. Linked-account sign-in never creates users implicitly. +No new database migration or Android binary change is needed. + +## Production verification (not proven by CI) + +1. Confirm `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` exist on the production web + service and the Google Cloud OAuth consent screen is published/available to testers. +2. Confirm the **Authorized redirect URI** is exactly + `https://food.bababuilds.com/api/auth/callback/google`. + This is unrelated to Android App Links / `assetlinks.json`. +3. Test on the installed Play closed-testing TWA using a fresh Google identity. Consent, + browser return, authenticated session, welcome onboarding and app relaunch must work. +4. Test a previously linked account and an email/password-only account with the same email: + the latter must remain unlinked until authenticated account-security linking. +5. Verify declining the legal checkbox blocks Google registration; manipulation of + `additionalData` and a missing or expired consent state cannot create a user. +6. Test both light/dark, narrow phone widths, keyboard accessibility and failure messages. +7. Verify an MFA-enabled account remains blocked by Google sign-in. +8. Record the deployed release SHA, Chrome version, device/Android version, and results. + +Google rejects sign-in inside some embedded WebViews. The Play client uses Chrome TWA: +do **not** add an unreviewed native WebView or pass Google tokens to Android Java code. From 9a3f039c9125c4ccd057ad275551ae6db4f8c30f Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 10:56:56 +0530 Subject: [PATCH 04/19] feat(auth): allow explicit Google signup without weakening MFA or linking --- src/lib/auth.ts | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e637648e..bb731902 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -10,7 +10,12 @@ import { env } from "@/lib/config/env"; import { hashPassword, verifyPassword } from "better-auth/crypto"; import bcrypt from "bcryptjs"; import { twoFactor, captcha } from "better-auth/plugins"; -import { APIError } from "better-auth/api"; +import { APIError, addOAuthServerContext, createAuthMiddleware, getOAuthState } from "better-auth/api"; +import { TERMS_VERSION } from "@/content/legal"; +import { + googleSignupConsentFromState, + googleSignupServerContext, +} from "@/lib/google-signup-consent"; import { recordAuthEvent, alertSecurityEvent } from "@/lib/security-events"; import { sendAuthEmail } from "@/lib/auth-email"; import { isDemoActor } from "@/modules/demo/guard"; @@ -35,6 +40,14 @@ export const authServer = betterAuth({ error: "mfa_requires_password", errorDescription: "This account requires password and authenticator sign-in.", }; + if (source.action === "create-user") { + const state = await getOAuthState().catch(() => null); + if (!googleSignupConsentFromState(state)) + return { + error: "terms_required", + errorDescription: "Accept the current terms on the registration page before continuing.", + }; + } }, additionalFields: { termsVersion: { type: "string", required: true, input: true }, @@ -45,14 +58,37 @@ export const authServer = betterAuth({ }, }, trustedOrigins: [new URL(env.APP_URL).origin], + hooks: { + before: createAuthMiddleware(async (ctx) => { + if (ctx.path !== "/sign-in/social") return; + const body = ctx.body as + | { provider?: unknown; requestSignUp?: unknown; additionalData?: unknown } + | undefined; + if (body?.provider !== "google" || body.requestSignUp !== true) return; + const consent = googleSignupServerContext(body.additionalData); + if (!consent) + throw new APIError("BAD_REQUEST", { + message: "Accept the current Terms and Privacy Policy before registering with Google.", + }); + // Only server-validated consent is persisted into OAuth state. The client cannot + // forge serverContext, and the callback cannot skip this user-creation gate. + await addOAuthServerContext(consent); + }), + }, socialProviders: env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, - disableSignUp: true, + // Returning sign-ins are never allowed to silently create a new user. + // Explicit Google registration supplies requestSignUp + server-trusted consent. + disableImplicitSignUp: true, disableIdTokenSignIn: true, + mapProfileToUser: async () => { + const state = await getOAuthState().catch(() => null); + return googleSignupConsentFromState(state) ?? {}; + }, requireEmailVerification: true, }, } From a3353bca87fd181194f1129cd3b8052e211f7e31 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 10:57:35 +0530 Subject: [PATCH 05/19] fix(mobile): surface Google sign-in and free signup before email form --- src/app/login/login-form.tsx | 94 ++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index d31dbd2f..ece0b97c 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useState, useSyncExternalStore } from "react"; -import { ArrowRight, ShieldCheck, UserRound, X } from "lucide-react"; +import { ArrowRight, ShieldCheck, UserRound, UserRoundPlus, X } from "lucide-react"; import { clearRememberedAccount, completePendingLoginPreference, @@ -52,15 +52,47 @@ export function LoginForm({ googleEnabled = false }: { googleEnabled?: boolean } eyebrow="SIGN IN / WELCOME BACK" title="Good to have you back." description="Pick up where you left off. Your restaurant stays yours, across every device." - extra={ -
- New to MenuMinute? - - Create an account - -
- } > +
+ {googleEnabled && ( + + )} + +
+
Or sign in with email
{params.get("error") === "mfa_requires_password" ? "This account is protected by an authenticator. Sign in with your email, password and verification code." - : "Google sign-in could not be completed. Sign in with your password first, then connect Google in Account Security."} + : params.get("error") === "account_not_linked" + ? "That Google account is not connected yet. Sign in with your password, then link Google in Account Security." + : params.get("error") === "signup_disabled" || params.get("error") === "terms_required" + ? "New to MenuMinute? Choose Create a free account to register with Google or email." + : "Google sign-in could not be completed. If your account is not yet linked, sign in with your password first."}

)} {error && ( @@ -230,44 +266,6 @@ export function LoginForm({ googleEnabled = false }: { googleEnabled?: boolean } {!busy &&