From 922d1782513550b38cb69a3486bfcf098e9738f0 Mon Sep 17 00:00:00 2001 From: Andrea Bugeja <25248102+arogan178@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:03:21 +0200 Subject: [PATCH 1/2] fix(android): sign in with Google through a backend-verified ticket Clerk refuses every ID token the Android Google picker returns, for sign-in and sign-up alike, because the token's azp names the Android OAuth client. The picker cannot mint one without it, so native Google sign-in never worked. The backend now verifies the token itself (Google's keys, our web client as audience, our Android clients as azp, verified email), finds the Clerk user who linked that exact Google account, and returns a 60-second sign-in ticket the app redeems. Email only narrows the lookup; an admin-asserted address never proves ownership. With no linked account the app continues to Clerk's web OAuth, which is where sign-up happens. Also fixes two picker bugs: the app opened the web login on top of the picker after 10 seconds or on cancel, and the plugin reused the last Google account so nobody could switch. Needs GOOGLE_WEB_CLIENT_ID and GOOGLE_ANDROID_CLIENT_IDS in the managed backend env; until both are set the route 404s and the app uses web OAuth. --- backend/.env.example | 6 + backend/src/config.ts | 5 + backend/src/lib/auth/google-native.ts | 132 +++++++++++++ backend/src/middleware/clerk-auth.ts | 2 + backend/src/middleware/rate-limit.ts | 1 + backend/src/modules/auth/routes.ts | 56 ++++++ backend/src/modules/auth/schemas.ts | 8 + backend/tests/lib/auth/google-native.test.ts | 151 ++++++++++++++ backend/tests/modules/auth/routes.test.ts | 86 +++++++- frontend/src/api/auth.ts | 8 + .../ClerkSignInForm.deviceTrust.test.tsx | 7 +- .../ClerkSignInForm.passwordReset.test.tsx | 7 +- .../auth/components/ClerkSignInForm.tsx | 55 ++---- .../ClerkSignUpForm.legalConsent.test.tsx | 7 +- .../auth/components/ClerkSignUpForm.tsx | 63 ++---- .../src/services/native/googleAuth.test.ts | 83 ++++++++ frontend/src/services/native/googleAuth.ts | 186 +++++------------- 17 files changed, 618 insertions(+), 245 deletions(-) create mode 100644 backend/src/lib/auth/google-native.ts create mode 100644 backend/tests/lib/auth/google-native.test.ts create mode 100644 frontend/src/services/native/googleAuth.test.ts diff --git a/backend/.env.example b/backend/.env.example index 8ed569e1..3c1948bc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,3 +33,9 @@ ENABLE_METRICS=false # publicly reachable: without it /metrics serves the full registry to anyone. # Send it as `X-Metrics-Key` or `Authorization: Bearer `. # METRICS_API_KEY= + +# --- Native Google sign-in on Android (managed, AUTH_MODE=clerk) --- +# The web OAuth client the app's Google picker requests tokens for, and the +# Android clients allowed to request them (comma-separated: debug and Play). +# GOOGLE_WEB_CLIENT_ID= +# GOOGLE_ANDROID_CLIENT_IDS= diff --git a/backend/src/config.ts b/backend/src/config.ts index 05c02ab1..767c227b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -72,6 +72,11 @@ const BaseEnvSchema = z.object({ CLERK_PUBLISHABLE_KEY: z.string().optional(), CLERK_SECRET_KEY: z.string().optional(), CLERK_WEBHOOK_SECRET: z.string().optional(), + // Native Google sign-in on Android. Clerk refuses the picker's ID tokens + // because their `azp` is the Android client, so the backend verifies them. + GOOGLE_WEB_CLIENT_ID: z.string().optional(), + // Comma-separated: the debug and Play-signed builds have separate clients. + GOOGLE_ANDROID_CLIENT_IDS: z.string().optional(), POSTHOG_KEY: z.string().optional(), POSTHOG_HOST: z.url("POSTHOG_HOST must be a valid URL").optional(), diff --git a/backend/src/lib/auth/google-native.ts b/backend/src/lib/auth/google-native.ts new file mode 100644 index 00000000..72ec1cc8 --- /dev/null +++ b/backend/src/lib/auth/google-native.ts @@ -0,0 +1,132 @@ +import type { ClerkClient } from "elysia-clerk"; + +const GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"; +const GOOGLE_ISSUERS = new Set(["accounts.google.com", "https://accounts.google.com"]); +const CLOCK_SKEW_SECONDS = 60; + +type GoogleKey = JsonWebKey & { kid?: string }; + +let cachedKeys: { keys: GoogleKey[]; expiresAt: number } | null = null; + +async function getGoogleKey(kid: string): Promise { + const find = () => cachedKeys?.keys.find((key) => key.kid === kid); + if (cachedKeys && cachedKeys.expiresAt > Date.now() && find()) { + return find(); + } + + const response = await fetch(GOOGLE_CERTS_URL); + if (!response.ok) { + throw new Error(`Google certificates request failed: ${response.status}`); + } + const maxAge = Number( + /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "")?.[1] ?? 3600, + ); + const { keys } = (await response.json()) as { keys: GoogleKey[] }; + cachedKeys = { keys, expiresAt: Date.now() + maxAge * 1000 }; + + return find(); +} + +export function resetGoogleKeyCache(): void { + cachedKeys = null; +} + +function decodeSegment(segment: string): unknown { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); +} + +export interface GoogleIdentity { + sub: string; + email: string; +} + +/** + * Returns the identity only for a Google-signed token minted for our web + * client at the request of one of our Android clients, with a verified email. + */ +export async function verifyGoogleIdToken( + idToken: string, + { webClientId, androidClientIds }: { webClientId: string; androidClientIds: string[] }, +): Promise { + const [headerPart, payloadPart, signaturePart] = idToken.split("."); + if (!headerPart || !payloadPart || !signaturePart) { + return null; + } + + let header: { alg?: string; kid?: string }; + let payload: Record; + try { + header = decodeSegment(headerPart) as typeof header; + payload = decodeSegment(payloadPart) as typeof payload; + } catch { + return null; + } + if (header.alg !== "RS256" || !header.kid) { + return null; + } + + const jwk = await getGoogleKey(header.kid); + if (!jwk) { + return null; + } + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + const signed = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + Buffer.from(signaturePart, "base64url"), + new TextEncoder().encode(`${headerPart}.${payloadPart}`), + ); + if (!signed) { + return null; + } + + const now = Math.floor(Date.now() / 1000); + const { iss, aud, azp, exp, email_verified, sub, email } = payload; + if ( + typeof iss !== "string" || + !GOOGLE_ISSUERS.has(iss) || + aud !== webClientId || + typeof azp !== "string" || + !androidClientIds.includes(azp) || + typeof exp !== "number" || + exp + CLOCK_SKEW_SECONDS < now || + email_verified !== true || + typeof sub !== "string" || + typeof email !== "string" + ) { + return null; + } + + return { sub, email }; +} + +/** + * The Clerk user who linked this exact Google account through Clerk's own + * OAuth. Email only narrows the search: an address on a Clerk user can be + * admin-asserted, so it never proves ownership on its own. + */ +export async function findClerkUserForGoogle( + clerk: Pick, + identity: GoogleIdentity, +): Promise { + const { data } = await clerk.users.getUserList({ + emailAddress: [identity.email], + limit: 20, + }); + const owner = data.find((user) => + user.externalAccounts.some( + (account) => + account.provider === "oauth_google" && + account.providerUserId === identity.sub && + account.verification?.status === "verified", + ), + ); + + return owner?.id ?? null; +} diff --git a/backend/src/middleware/clerk-auth.ts b/backend/src/middleware/clerk-auth.ts index c103894b..3ee7c536 100644 --- a/backend/src/middleware/clerk-auth.ts +++ b/backend/src/middleware/clerk-auth.ts @@ -36,6 +36,8 @@ const AUTH_EXEMPT_PATHS = new Set([ "/api/billing/webhook", // Public: the pricing page has to know what it may sell before sign-in. "/api/billing/capabilities", + // Public: proves who the caller is with a Google ID token instead. + "/api/auth/google-native", "/api/docs", "/api/docs/json", "/", diff --git a/backend/src/middleware/rate-limit.ts b/backend/src/middleware/rate-limit.ts index 0ee5438b..1cfc3fb4 100644 --- a/backend/src/middleware/rate-limit.ts +++ b/backend/src/middleware/rate-limit.ts @@ -155,6 +155,7 @@ const SENSITIVE_AUTH_PATHS = new Set([ "/api/auth/login", "/api/auth/register", "/api/auth/change-password", + "/api/auth/google-native", ]); export function isSensitiveAuthRequest(request: Request): boolean { diff --git a/backend/src/modules/auth/routes.ts b/backend/src/modules/auth/routes.ts index 0ff3a24c..d4fafcba 100644 --- a/backend/src/modules/auth/routes.ts +++ b/backend/src/modules/auth/routes.ts @@ -1,4 +1,5 @@ import type { Database } from "bun:sqlite"; +import type { ClerkClient } from "elysia-clerk"; import { Elysia } from "elysia"; import { type UserRow, @@ -27,6 +28,10 @@ import { readSessionTokenFromRequest, } from "../../lib/auth/session"; import { publishUserSyncEvent } from "../../lib/sync/eventBus"; +import { + findClerkUserForGoogle, + verifyGoogleIdToken, +} from "../../lib/auth/google-native"; import { getConfig } from "../../config"; import { captureProductEvent } from "../../lib/analytics/product-analytics"; import { AuthSchemas } from "./schemas"; @@ -696,6 +701,57 @@ export const authRoutes = new Elysia({ prefix: "/api/auth" }) tags: ["Auth"], }, }, + ) + .post( + "/google-native", + async (context) => { + const config = getConfig(); + const { clerk } = context as unknown as { clerk?: ClerkClient }; + const androidClientIds = (config.GOOGLE_ANDROID_CLIENT_IDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + if ( + config.AUTH_MODE !== "clerk" || + !clerk || + !config.GOOGLE_WEB_CLIENT_ID || + androidClientIds.length === 0 + ) { + context.set.status = 404; + throw new NotFoundError("Not found"); + } + + const identity = await verifyGoogleIdToken(context.body.idToken, { + webClientId: config.GOOGLE_WEB_CLIENT_ID, + androidClientIds, + }); + if (!identity) { + context.set.status = 401; + throw new AuthenticationError("Google sign-in could not be verified."); + } + + // No linked account: the app signs up through Clerk's web OAuth. + const clerkUserId = await findClerkUserForGoogle(clerk, identity); + if (!clerkUserId) { + return { ticket: null }; + } + + const { token } = await clerk.signInTokens.createSignInToken({ + userId: clerkUserId, + expiresInSeconds: 60, + }); + logger.info({ clerkUserId }, "[google-native] Issued sign-in ticket"); + + return { ticket: token }; + }, + { + body: AuthSchemas.googleNative, + response: AuthSchemas.googleNativeResponse, + detail: { + summary: "Exchange an Android Google ID token for a Clerk sign-in ticket", + tags: ["Auth"], + }, + }, ); export function isSessionFresh(lastUsedAt: string): boolean { diff --git a/backend/src/modules/auth/schemas.ts b/backend/src/modules/auth/schemas.ts index 0560b653..d9ea5077 100644 --- a/backend/src/modules/auth/schemas.ts +++ b/backend/src/modules/auth/schemas.ts @@ -38,6 +38,14 @@ export const AuthSchemas = { successResponse: SuccessResponseSchema, + googleNative: t.Object({ + idToken: t.String({ minLength: 1, maxLength: 4096 }), + }), + + googleNativeResponse: t.Object({ + ticket: t.Nullable(t.String()), + }), + authResponse: t.Object({ success: t.Boolean(), message: t.Optional(t.String()), diff --git a/backend/tests/lib/auth/google-native.test.ts b/backend/tests/lib/auth/google-native.test.ts new file mode 100644 index 00000000..f6fe3763 --- /dev/null +++ b/backend/tests/lib/auth/google-native.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + findClerkUserForGoogle, + resetGoogleKeyCache, + verifyGoogleIdToken, +} from "../../../src/lib/auth/google-native"; + +const WEB = "web-client.apps.googleusercontent.com"; +const ANDROID = "android-client.apps.googleusercontent.com"; +const KID = "google-key-1"; + +async function generateKeyPair() { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + + return { + privateJwk: await crypto.subtle.exportKey("jwk", pair.privateKey), + publicJwk: await crypto.subtle.exportKey("jwk", pair.publicKey), + }; +} + +let google: Awaited>; +let attacker: Awaited>; + +beforeAll(async () => { + google = await generateKeyPair(); + attacker = await generateKeyPair(); +}); + +beforeEach(() => { + resetGoogleKeyCache(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ keys: [{ ...google.publicJwk, kid: KID, alg: "RS256" }] }), { + headers: { "cache-control": "public, max-age=3600" }, + }), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function sign( + claims: Record = {}, + key: JsonWebKey = google.privateJwk, + header: Record = { alg: "RS256", kid: KID, typ: "JWT" }, +) { + const now = Math.floor(Date.now() / 1000); + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const signingInput = `${encode(header)}.${encode({ + iss: "https://accounts.google.com", + aud: WEB, + azp: ANDROID, + sub: "google-sub-1", + email: "player@gmail.com", + email_verified: true, + iat: now, + exp: now + 3600, + ...claims, + })}`; + const privateKey = await crypto.subtle.importKey( + "jwk", + key, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + new TextEncoder().encode(signingInput), + ); + + return `${signingInput}.${Buffer.from(signature).toString("base64url")}`; +} + +const verify = (token: string) => + verifyGoogleIdToken(token, { webClientId: WEB, androidClientIds: ["debug-client", ANDROID] }); + +describe("verifyGoogleIdToken", () => { + it("returns the identity of a valid token from one of our Android clients", async () => { + expect(await verify(await sign())).toEqual({ sub: "google-sub-1", email: "player@gmail.com" }); + }); + + it.each([ + ["signed by another key", () => sign({}, attacker.privateJwk)], + ["minted for another audience", () => sign({ aud: "someone-else" })], + ["requested by another app", () => sign({ azp: "someone-elses-android-client" })], + ["missing azp", () => sign({ azp: undefined })], + ["with an unverified email", () => sign({ email_verified: false })], + ["from another issuer", () => sign({ iss: "https://evil.example" })], + ["expired", () => sign({ exp: Math.floor(Date.now() / 1000) - 600 })], + ["claiming an unsigned algorithm", () => sign({}, google.privateJwk, { alg: "none", kid: KID })], + ["naming an unknown key", () => sign({}, google.privateJwk, { alg: "RS256", kid: "other" })], + ])("rejects a token %s", async (_label, makeToken) => { + expect(await verify(await makeToken())).toBeNull(); + }); + + it("rejects a token that is not a JWT", async () => { + expect(await verify("not-a-token")).toBeNull(); + }); + + it("reuses Google's keys until they expire", async () => { + await verify(await sign()); + await verify(await sign()); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + +function clerkWithUsers(users: unknown[]) { + return { users: { getUserList: vi.fn().mockResolvedValue({ data: users }) } } as never; +} + +function googleAccount(providerUserId: string, status = "verified") { + return { provider: "oauth_google", providerUserId, verification: { status } }; +} + +describe("findClerkUserForGoogle", () => { + const identity = { sub: "google-sub-1", email: "player@gmail.com" }; + + it("finds the user who linked this Google account", async () => { + const clerk = clerkWithUsers([{ id: "user_owner", externalAccounts: [googleAccount("google-sub-1")] }]); + expect(await findClerkUserForGoogle(clerk, identity)).toBe("user_owner"); + }); + + it("ignores a user who only has the email address", async () => { + // An admin-asserted address must not hand its holder this Google identity. + const clerk = clerkWithUsers([{ id: "user_squatter", externalAccounts: [] }]); + expect(await findClerkUserForGoogle(clerk, identity)).toBeNull(); + }); + + it("ignores a different Google account on the same address", async () => { + const clerk = clerkWithUsers([{ id: "user_other", externalAccounts: [googleAccount("google-sub-2")] }]); + expect(await findClerkUserForGoogle(clerk, identity)).toBeNull(); + }); + + it("ignores a link that was never verified", async () => { + const clerk = clerkWithUsers([ + { id: "user_pending", externalAccounts: [googleAccount("google-sub-1", "unverified")] }, + ]); + expect(await findClerkUserForGoogle(clerk, identity)).toBeNull(); + }); +}); diff --git a/backend/tests/modules/auth/routes.test.ts b/backend/tests/modules/auth/routes.test.ts index ff98586e..61faae97 100644 --- a/backend/tests/modules/auth/routes.test.ts +++ b/backend/tests/modules/auth/routes.test.ts @@ -1,5 +1,5 @@ import { Elysia } from "elysia"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { handleError } from "../../../src/lib/http/responses"; @@ -40,6 +40,14 @@ vi.mock("../../../src/lib/auth/session", () => ({ readSessionTokenFromRequestMock(...arguments_), })); +const verifyGoogleIdTokenMock = vi.fn(); +const findClerkUserForGoogleMock = vi.fn(); +vi.mock("../../../src/lib/auth/google-native", () => ({ + verifyGoogleIdToken: (...arguments_: unknown[]) => verifyGoogleIdTokenMock(...arguments_), + findClerkUserForGoogle: (...arguments_: unknown[]) => + findClerkUserForGoogleMock(...arguments_), +})); + import { resetConfigCache } from "../../../src/config"; import { authRoutes } from "../../../src/modules/auth/routes"; @@ -76,6 +84,7 @@ interface AuthTestAppOptions { deleteUser?: (userId: string) => Promise; }; }; + clerk?: unknown; } function createAuthTestApp( @@ -97,7 +106,9 @@ function createAuthTestApp( options.clerkClient ? baseApp.decorate("clerkClient", options.clerkClient) : baseApp; - const scopedApp = appWithClerkClient as unknown as Elysia; + const appWithClerk = + options.clerk ? appWithClerkClient.decorate("clerk", options.clerk) : appWithClerkClient; + const scopedApp = appWithClerk as unknown as Elysia; return scopedApp .derive({ as: "scoped" }, () => ({ @@ -909,4 +920,75 @@ describe("auth routes", () => { }); }); }); + + describe("POST /api/auth/google-native", () => { + const createSignInTokenMock = vi.fn(); + const clerk = { signInTokens: { createSignInToken: createSignInTokenMock } }; + const identity = { sub: "google-sub-1", email: "player@gmail.com" }; + + beforeEach(() => { + process.env.APP_MODE = "managed"; + process.env.AUTH_MODE = "clerk"; + process.env.BILLING_MODE = "managed"; + process.env.GOOGLE_WEB_CLIENT_ID = "web-client"; + process.env.GOOGLE_ANDROID_CLIENT_IDS = "debug-client, play-client"; + resetConfigCache(); + verifyGoogleIdTokenMock.mockReset().mockResolvedValue(identity); + findClerkUserForGoogleMock.mockReset().mockResolvedValue("user_owner"); + createSignInTokenMock.mockReset().mockResolvedValue({ token: "ticket_1" }); + }); + + afterEach(() => { + delete process.env.GOOGLE_WEB_CLIENT_ID; + delete process.env.GOOGLE_ANDROID_CLIENT_IDS; + }); + + it("issues a short-lived ticket for the linked account", async () => { + const app = createAuthTestApp(fakeDb, { clerk }); + + const response = await postJson(app, "/api/auth/google-native", { idToken: "token" }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ticket: "ticket_1" }); + expect(verifyGoogleIdTokenMock).toHaveBeenCalledWith("token", { + webClientId: "web-client", + androidClientIds: ["debug-client", "play-client"], + }); + expect(createSignInTokenMock).toHaveBeenCalledWith({ + userId: "user_owner", + expiresInSeconds: 60, + }); + }); + + it("returns no ticket when no account has this Google login", async () => { + findClerkUserForGoogleMock.mockResolvedValue(null); + const app = createAuthTestApp(fakeDb, { clerk }); + + const response = await postJson(app, "/api/auth/google-native", { idToken: "token" }); + + await expect(response.json()).resolves.toEqual({ ticket: null }); + expect(createSignInTokenMock).not.toHaveBeenCalled(); + }); + + it("refuses a token that does not verify", async () => { + verifyGoogleIdTokenMock.mockResolvedValue(null); + const app = createAuthTestApp(fakeDb, { clerk }); + + const response = await postJson(app, "/api/auth/google-native", { idToken: "token" }); + + expect(response.status).toBe(401); + expect(createSignInTokenMock).not.toHaveBeenCalled(); + }); + + it("is not found until both client IDs are configured", async () => { + delete process.env.GOOGLE_ANDROID_CLIENT_IDS; + resetConfigCache(); + const app = createAuthTestApp(fakeDb, { clerk }); + + const response = await postJson(app, "/api/auth/google-native", { idToken: "token" }); + + expect(response.status).toBe(404); + expect(verifyGoogleIdTokenMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 2b765d90..7279d110 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -107,6 +107,14 @@ export const authApi = { }: ChangePasswordPayload): Promise<{ success: boolean; message?: string }> => unwrap(api.api.auth["change-password"].post({ currentPassword, newPassword })), + /** + * Swaps an Android Google ID token for a Clerk sign-in ticket, or null when + * no account has that Google login linked. + * @throws {ApiError} + */ + exchangeGoogleIdToken: async (idToken: string): Promise<{ ticket: string | null }> => + unwrap(api.api.auth["google-native"].post({ idToken })), + /** * @throws {ApiError} */ diff --git a/frontend/src/features/auth/components/ClerkSignInForm.deviceTrust.test.tsx b/frontend/src/features/auth/components/ClerkSignInForm.deviceTrust.test.tsx index 31a11249..5e95c037 100644 --- a/frontend/src/features/auth/components/ClerkSignInForm.deviceTrust.test.tsx +++ b/frontend/src/features/auth/components/ClerkSignInForm.deviceTrust.test.tsx @@ -65,10 +65,6 @@ vi.mock("motion/react", () => { }; }); -vi.mock("@clerk/react", () => ({ - useClerk: () => ({}), -})); - vi.mock("@clerk/react/legacy", () => ({ useSignIn: () => ({ isLoaded: true, @@ -95,8 +91,7 @@ vi.mock("@/services/biometrics", () => ({ })); vi.mock("@/services/native/googleAuth", () => ({ - exchangeNativeGoogleTokenWithClerk: vi.fn(), - nativeGoogleSignIn: vi.fn(), + signInWithNativeGoogle: vi.fn(), })); vi.mock("@/services/native/platform", () => ({ diff --git a/frontend/src/features/auth/components/ClerkSignInForm.passwordReset.test.tsx b/frontend/src/features/auth/components/ClerkSignInForm.passwordReset.test.tsx index 69553c01..5ea8e9dd 100644 --- a/frontend/src/features/auth/components/ClerkSignInForm.passwordReset.test.tsx +++ b/frontend/src/features/auth/components/ClerkSignInForm.passwordReset.test.tsx @@ -67,10 +67,6 @@ vi.mock("motion/react", () => { }; }); -vi.mock("@clerk/react", () => ({ - useClerk: () => ({}), -})); - vi.mock("@clerk/react/legacy", () => ({ useSignIn: () => ({ isLoaded: true, @@ -98,8 +94,7 @@ vi.mock("@/services/biometrics", () => ({ })); vi.mock("@/services/native/googleAuth", () => ({ - exchangeNativeGoogleTokenWithClerk: vi.fn(), - nativeGoogleSignIn: vi.fn(), + signInWithNativeGoogle: vi.fn(), })); vi.mock("@/services/native/platform", () => ({ diff --git a/frontend/src/features/auth/components/ClerkSignInForm.tsx b/frontend/src/features/auth/components/ClerkSignInForm.tsx index a7069671..e6805b68 100644 --- a/frontend/src/features/auth/components/ClerkSignInForm.tsx +++ b/frontend/src/features/auth/components/ClerkSignInForm.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from "react"; import { Browser } from "@capacitor/browser"; -import { useClerk } from "@clerk/react"; import { useSignIn, useSignUp } from "@clerk/react/legacy"; import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; @@ -34,8 +33,7 @@ import { resolveSocialAuthError } from "@/features/auth/utils/socialAuth"; import { logger } from "@/lib/logger"; import { saveBiometricCredentials } from "@/services/biometrics"; import { - exchangeNativeGoogleTokenWithClerk, - nativeGoogleSignIn, + signInWithNativeGoogle, } from "@/services/native/googleAuth"; import { isNativePlatform } from "@/services/native/platform"; import { useStore } from "@/store/store"; @@ -105,7 +103,6 @@ export function ClerkSignInForm({ redirectTo, }: ClerkSignInFormProps) { const navigate = useNavigate(); - const clerk = useClerk(); const { isLoaded, signIn, setActive } = useSignIn(); const { isLoaded: isSignUpLoaded, signUp } = useSignUp(); const { showNotification } = useStore(); @@ -336,45 +333,25 @@ export function ClerkSignInForm({ try { if (strategy === "oauth_google" && isNativePlatform()) { - logger.info("Initiating native Google Sign-In on mobile..."); - let googleAuthRes = null; - try { - googleAuthRes = await Promise.race([ - nativeGoogleSignIn(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Native Google Sign-In timed out")), 10000) - ), - ]); - } catch (nativeError) { - logger.warn("Native Google Sign-In failed or timed out:", nativeError); - } - - if (googleAuthRes?.idToken) { - logger.info("Native Google Sign-In obtained idToken, exchanging with Clerk..."); - const success = await exchangeNativeGoogleTokenWithClerk({ - idToken: googleAuthRes.idToken, - clerk, - signIn, - signUp, - setActive, + const outcome = await signInWithNativeGoogle({ signIn, setActive }); + if (outcome === "signed_in") { + showNotification("Signed in with Google successfully!", "success"); + navigate({ + to: "/auth-ready", + search: { redirectTo: normalizedRedirect }, + replace: true, }); - if (success) { - showNotification("Signed in with Google successfully!", "success"); - navigate({ - to: "/auth-ready", - search: { redirectTo: normalizedRedirect }, - replace: true, - }); - - return; - } - logger.warn("Native Google token exchange returned false."); - showNotification("Google sign-in could not be completed. Please try again in a moment.", "warning"); + return; + } + if (outcome === "cancelled") return; + if (outcome === "failed") { + showNotification( + "Google sign-in could not be completed. Please try again in a moment.", + "warning", + ); return; - } else { - logger.info("Native Google Sign-In returned no idToken or timed out, falling back to web OAuth..."); } } diff --git a/frontend/src/features/auth/components/ClerkSignUpForm.legalConsent.test.tsx b/frontend/src/features/auth/components/ClerkSignUpForm.legalConsent.test.tsx index 3e1136b4..cea89fd0 100644 --- a/frontend/src/features/auth/components/ClerkSignUpForm.legalConsent.test.tsx +++ b/frontend/src/features/auth/components/ClerkSignUpForm.legalConsent.test.tsx @@ -71,10 +71,6 @@ vi.mock("motion/react", () => { }; }); -vi.mock("@clerk/react", () => ({ - useClerk: () => ({}), -})); - vi.mock("@clerk/react/legacy", () => ({ useSignUp: () => ({ isLoaded: true, @@ -120,8 +116,7 @@ vi.mock("@/lib/productAnalytics", () => ({ })); vi.mock("@/services/native/googleAuth", () => ({ - exchangeNativeGoogleTokenWithClerk: vi.fn(), - nativeGoogleSignIn: vi.fn(), + signInWithNativeGoogle: vi.fn(), })); vi.mock("@/services/native/platform", () => ({ diff --git a/frontend/src/features/auth/components/ClerkSignUpForm.tsx b/frontend/src/features/auth/components/ClerkSignUpForm.tsx index d20c1653..e0f0887f 100644 --- a/frontend/src/features/auth/components/ClerkSignUpForm.tsx +++ b/frontend/src/features/auth/components/ClerkSignUpForm.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Browser } from "@capacitor/browser"; -import { useClerk } from "@clerk/react"; import { useSignIn, useSignUp } from "@clerk/react/legacy"; import { resolveSignupSource } from "@shared/product-analytics"; import { useNavigate } from "@tanstack/react-router"; @@ -34,8 +33,7 @@ import { import { logger } from "@/lib/logger"; import { useProductAnalytics } from "@/lib/productAnalytics"; import { - exchangeNativeGoogleTokenWithClerk, - nativeGoogleSignIn, + signInWithNativeGoogle, } from "@/services/native/googleAuth"; import { isNativePlatform } from "@/services/native/platform"; import { useStore } from "@/store/store"; @@ -88,7 +86,6 @@ export function ClerkSignUpForm({ }: ClerkSignUpFormProps) { const productAnalytics = useProductAnalytics(); const navigate = useNavigate(); - const clerk = useClerk(); const { isLoaded, signUp, setActive } = useSignUp(); const { isLoaded: isSignInLoaded, @@ -236,58 +233,28 @@ export function ClerkSignUpForm({ try { if (strategy === "oauth_google" && isNativePlatform()) { - logger.info("Initiating native Google Sign-In on mobile..."); - let googleAuthRes = null; - try { - googleAuthRes = await Promise.race([ - nativeGoogleSignIn(), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Native Google Sign-In timed out")), - 10000, - ), - ), - ]); - } catch (nativeError) { - logger.warn( - "Native Google Sign-In failed or timed out:", - nativeError, - ); - } - - if (googleAuthRes?.idToken) { - logger.info( - "Native Google Sign-In obtained idToken, exchanging with Clerk...", - ); - const success = await exchangeNativeGoogleTokenWithClerk({ - idToken: googleAuthRes.idToken, - clerk, - signIn, - signUp, - setActive, + const outcome = await signInWithNativeGoogle({ + signIn, + setActive: setSignInActive, + }); + if (outcome === "signed_in") { + showNotification("Signed in with Google successfully!", "success"); + navigate({ + to: "/auth-ready", + search: { redirectTo: normalizedRedirect }, + replace: true, }); - if (success) { - showNotification("Signed in with Google successfully!", "success"); - navigate({ - to: "/auth-ready", - search: { redirectTo: normalizedRedirect }, - replace: true, - }); - - return; - } - logger.warn("Native Google token exchange returned false."); + return; + } + if (outcome === "cancelled") return; + if (outcome === "failed") { showNotification( "Google sign-in could not be completed. Please try again in a moment.", "warning", ); return; - } else { - logger.info( - "Native Google Sign-In returned no idToken or timed out, falling back to web OAuth...", - ); } } diff --git a/frontend/src/services/native/googleAuth.test.ts b/frontend/src/services/native/googleAuth.test.ts new file mode 100644 index 00000000..0df167c2 --- /dev/null +++ b/frontend/src/services/native/googleAuth.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/api/core"; + +import { signInWithNativeGoogle } from "./googleAuth"; + +const plugin = vi.hoisted(() => ({ + initialize: vi.fn(), + signOut: vi.fn(), + signIn: vi.fn(), +})); +const exchangeGoogleIdToken = vi.hoisted(() => vi.fn()); + +vi.mock("@codetrix-studio/capacitor-google-auth", () => ({ GoogleAuth: plugin })); +vi.mock("@/api/auth", () => ({ authApi: { exchangeGoogleIdToken } })); +vi.mock("./platform", () => ({ isNativePlatform: () => true })); + +const setActive = vi.fn(); +const signIn = { create: vi.fn() }; +const run = () => signInWithNativeGoogle({ signIn, setActive }); + +beforeEach(() => { + vi.clearAllMocks(); + plugin.signOut.mockResolvedValue(undefined); + plugin.signIn.mockResolvedValue({ authentication: { idToken: "google-id-token" } }); +}); + +describe("signInWithNativeGoogle", () => { + it("signs in with the ticket the backend issues", async () => { + exchangeGoogleIdToken.mockResolvedValue({ ticket: "ticket_1" }); + signIn.create.mockResolvedValue({ status: "complete", createdSessionId: "sess_1" }); + + expect(await run()).toBe("signed_in"); + expect(signIn.create).toHaveBeenCalledWith({ strategy: "ticket", ticket: "ticket_1" }); + expect(setActive).toHaveBeenCalledWith({ session: "sess_1" }); + }); + + it("forgets the last account so the picker always shows", async () => { + exchangeGoogleIdToken.mockResolvedValue({ ticket: null }); + await run(); + expect(plugin.signOut.mock.invocationCallOrder[0]).toBeLessThan( + plugin.signIn.mock.invocationCallOrder[0]!, + ); + }); + + it("stops when the picker is cancelled", async () => { + plugin.signIn.mockRejectedValue(new Error("12501: The user canceled the sign-in flow.")); + + expect(await run()).toBe("cancelled"); + expect(exchangeGoogleIdToken).not.toHaveBeenCalled(); + }); + + it.each([ + ["the picker fails", () => plugin.signIn.mockRejectedValue(new Error("Something went wrong"))], + ["no account has this Google login", () => exchangeGoogleIdToken.mockResolvedValue({ ticket: null })], + [ + "the backend has native sign-in switched off", + () => exchangeGoogleIdToken.mockRejectedValue(new ApiError("Not found", 404, "NOT_FOUND")), + ], + ])("falls back to the web flow when %s", async (_label, arrange) => { + arrange(); + expect(await run()).toBe("use_web"); + expect(setActive).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "the token is refused", + () => exchangeGoogleIdToken.mockRejectedValue(new ApiError("Unverified", 401, "AUTHENTICATION_ERROR")), + ], + [ + "Clerk refuses the ticket", + () => { + exchangeGoogleIdToken.mockResolvedValue({ ticket: "ticket_1" }); + signIn.create.mockRejectedValue(new Error("ticket expired")); + }, + ], + ])("fails when %s", async (_label, arrange) => { + arrange(); + expect(await run()).toBe("failed"); + expect(setActive).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/services/native/googleAuth.ts b/frontend/src/services/native/googleAuth.ts index 6d6d5243..cb22c15c 100644 --- a/frontend/src/services/native/googleAuth.ts +++ b/frontend/src/services/native/googleAuth.ts @@ -1,5 +1,7 @@ import { GoogleAuth } from "@codetrix-studio/capacitor-google-auth"; +import { authApi } from "@/api/auth"; +import { ApiError } from "@/api/core"; import { logger } from "@/lib/logger"; import { isNativePlatform } from "./platform"; @@ -26,19 +28,20 @@ export function initNativeGoogleAuth(): void { } } -export async function nativeGoogleSignIn(): Promise<{ idToken: string; email?: string } | null> { - if (!isNativePlatform()) return null; +type GoogleSignInResult = { idToken: string } | "cancelled" | null; +async function pickGoogleAccount(): Promise { try { initNativeGoogleAuth(); + // The plugin silently reuses the last account; forgetting it first shows + // the picker, so someone with two Google accounts can choose. + await GoogleAuth.signOut().catch(() => undefined); const user = await GoogleAuth.signIn(); - // The token only ever hangs off `authentication`; the old `user.idToken` - // fallback read a property the plugin does not define. const idToken = user?.authentication?.idToken; if (idToken) { - return { idToken, email: user.email }; + return { idToken }; } - logger.warn("Native GoogleAuth succeeded but no idToken returned:", user); + logger.warn("Native GoogleAuth succeeded but no idToken returned"); return null; } catch (error: any) { @@ -49,155 +52,62 @@ export async function nativeGoogleSignIn(): Promise<{ idToken: string; email?: s errString.includes("popup_closed") || error?.code === 12501 ) { - logger.info("Native GoogleAuth user cancelled sign-in"); - - return null; + return "cancelled"; } - logger.warn("Native GoogleAuth signIn failed or cancelled:", error); + logger.warn("Native GoogleAuth signIn failed:", error); return null; } } -export async function exchangeNativeGoogleTokenWithClerk({ - idToken, - clerk, +/** + * `use_web` means continue with Clerk's web OAuth: the picker failed, or no + * account has this Google login linked yet, so it has to sign up there. + */ +export type NativeGoogleOutcome = "signed_in" | "cancelled" | "use_web" | "failed"; + +/** + * Clerk refuses the picker's ID token itself (its `azp` is the Android client), + * so the backend verifies it and returns a one-time sign-in ticket. + */ +export async function signInWithNativeGoogle({ signIn, - signUp, setActive, }: { - idToken: string; - clerk?: any; - signIn?: any; - signUp?: any; + signIn: { + create: (params: { strategy: "ticket"; ticket: string }) => Promise<{ + status: string | null; + createdSessionId: string | null; + }>; + }; setActive: (params: { session: string }) => Promise; -}): Promise { - const getSessionId = (res: any) => - res?.createdSessionId || - res?.signIn?.createdSessionId || - res?.signUp?.createdSessionId || - (res?.status === "complete" ? res?.createdSessionId : null); - - const tryTransfer = async (): Promise => { - try { - logger.info("[ClerkNativeAuth] Attempting signIn.create({ transfer: true })..."); - const transferRes = await signIn?.create?.({ transfer: true }); - const transferSessionId = getSessionId(transferRes); - if (transferSessionId) { - logger.info("[ClerkNativeAuth] signIn.create({ transfer: true }) succeeded!", transferSessionId); - await setActive({ session: transferSessionId }); - - return true; - } - if (transferRes?.status === "complete") { - logger.info("[ClerkNativeAuth] signIn.create({ transfer: true }) status complete:", transferRes); - - return true; - } - } catch (transferError: any) { - const message = transferError?.errors - ? JSON.stringify(transferError.errors) - : transferError?.message || JSON.stringify(transferError); - logger.warn(`[ClerkNativeAuth] transfer failed: ${message}`); - } +}): Promise { + const picked = await pickGoogleAccount(); + if (picked === "cancelled") return "cancelled"; + if (!picked) return "use_web"; - return false; - }; + let ticket: string | null; + try { + ({ ticket } = await authApi.exchangeGoogleIdToken(picked.idToken)); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return "use_web"; + logger.warn("Google ID token exchange failed:", error); - // Step 1: Official Clerk method for Google ID Tokens - if (clerk?.authenticateWithGoogleOneTap) { - try { - logger.info("[ClerkNativeAuth] Attempting clerk.authenticateWithGoogleOneTap..."); - const res = await clerk.authenticateWithGoogleOneTap({ token: idToken }); - const createdSessionId = getSessionId(res); - - if (createdSessionId) { - logger.info("[ClerkNativeAuth] clerk.authenticateWithGoogleOneTap succeeded!", createdSessionId); - await setActive({ session: createdSessionId }); - - return true; - } - - if (clerk?.handleGoogleOneTapCallback) { - try { - await clerk.handleGoogleOneTapCallback(res, { - signInFallbackRedirectUrl: "/sso-callback", - }); - - return true; - } catch (callbackError) { - logger.warn("[ClerkNativeAuth] handleGoogleOneTapCallback warning:", callbackError); - } - } - - if (res?.status === "complete") { - logger.info("[ClerkNativeAuth] clerk.authenticateWithGoogleOneTap complete:", res); - - return true; - } - } catch (err: any) { - const isTooManyRequests = err?.errors?.some((e: any) => e?.code === "too_many_requests") || err?.code === "too_many_requests"; - const isExternalAccountExists = err?.errors?.some( - (e: any) => e?.code === "external_account_exists" || e?.code === "form_identifier_exists" - ); - const message = err?.errors - ? JSON.stringify(err.errors) - : err?.message || JSON.stringify(err); - logger.warn(`[ClerkNativeAuth] clerk.authenticateWithGoogleOneTap failed: ${message}`); - - if (isTooManyRequests) { - logger.warn("[ClerkNativeAuth] Rate limit reached on Clerk API."); - - return false; - } - - if (isExternalAccountExists) { - const transferred = await tryTransfer(); - if (transferred) return true; - } - } + return "failed"; } + if (!ticket) return "use_web"; - // Step 2: Fallback attempt via signIn / signUp google_one_tap (single pair) try { - logger.info("[ClerkNativeAuth] Trying signIn.create({ strategy: 'google_one_tap' })..."); - const res = await signIn?.create?.({ strategy: "google_one_tap", token: idToken }); - const createdSessionId = getSessionId(res); - if (createdSessionId) { - await setActive({ session: createdSessionId }); - - return true; - } - } catch (err: any) { - const isTooManyRequests = err?.errors?.some((e: any) => e?.code === "too_many_requests"); - const isExternalAccountExists = err?.errors?.some( - (e: any) => e?.code === "external_account_exists" || e?.code === "form_identifier_exists" - ); - if (isTooManyRequests) return false; - if (isExternalAccountExists) { - const transferred = await tryTransfer(); - if (transferred) return true; - } + const result = await signIn.create({ strategy: "ticket", ticket }); + if (result.status === "complete" && result.createdSessionId) { + await setActive({ session: result.createdSessionId }); - try { - logger.info("[ClerkNativeAuth] Trying signUp.create({ strategy: 'google_one_tap' })..."); - const signUpRes = await signUp?.create?.({ strategy: "google_one_tap", token: idToken }); - const createdSessionId = getSessionId(signUpRes); - if (createdSessionId) { - await setActive({ session: createdSessionId }); - - return true; - } - } catch (signUpError: any) { - const isTransferred = signUpError?.errors?.some( - (e: any) => e?.code === "external_account_exists" || e?.code === "form_identifier_exists" - ); - if (isTransferred) { - const transferred = await tryTransfer(); - if (transferred) return true; - } + return "signed_in"; } + logger.warn("Google sign-in ticket did not complete:", result.status); + } catch (error) { + logger.warn("Google sign-in ticket was refused:", error); } - return false; + return "failed"; } From 1bc6d44575c70ec85df21e82313865b2fd731cd4 Mon Sep 17 00:00:00 2001 From: Andrea Bugeja <25248102+arogan178@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:44:27 +0200 Subject: [PATCH 2/2] fix(android): fall back to web sign-in when a ticket needs a second factor Also time out the Google certificates request after five seconds. --- backend/src/lib/auth/google-native.ts | 2 +- frontend/src/services/native/googleAuth.test.ts | 7 +++++++ frontend/src/services/native/googleAuth.ts | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/src/lib/auth/google-native.ts b/backend/src/lib/auth/google-native.ts index 72ec1cc8..72902c8b 100644 --- a/backend/src/lib/auth/google-native.ts +++ b/backend/src/lib/auth/google-native.ts @@ -14,7 +14,7 @@ async function getGoogleKey(kid: string): Promise { return find(); } - const response = await fetch(GOOGLE_CERTS_URL); + const response = await fetch(GOOGLE_CERTS_URL, { signal: AbortSignal.timeout(5000) }); if (!response.ok) { throw new Error(`Google certificates request failed: ${response.status}`); } diff --git a/frontend/src/services/native/googleAuth.test.ts b/frontend/src/services/native/googleAuth.test.ts index 0df167c2..fe36ccfd 100644 --- a/frontend/src/services/native/googleAuth.test.ts +++ b/frontend/src/services/native/googleAuth.test.ts @@ -57,6 +57,13 @@ describe("signInWithNativeGoogle", () => { "the backend has native sign-in switched off", () => exchangeGoogleIdToken.mockRejectedValue(new ApiError("Not found", 404, "NOT_FOUND")), ], + [ + "the account needs a second factor", + () => { + exchangeGoogleIdToken.mockResolvedValue({ ticket: "ticket_1" }); + signIn.create.mockResolvedValue({ status: "needs_second_factor", createdSessionId: null }); + }, + ], ])("falls back to the web flow when %s", async (_label, arrange) => { arrange(); expect(await run()).toBe("use_web"); diff --git a/frontend/src/services/native/googleAuth.ts b/frontend/src/services/native/googleAuth.ts index cb22c15c..3568a8a9 100644 --- a/frontend/src/services/native/googleAuth.ts +++ b/frontend/src/services/native/googleAuth.ts @@ -104,7 +104,10 @@ export async function signInWithNativeGoogle({ return "signed_in"; } + // A second factor can't be completed here; the web flow can. logger.warn("Google sign-in ticket did not complete:", result.status); + + return "use_web"; } catch (error) { logger.warn("Google sign-in ticket was refused:", error); }