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..72902c8b --- /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, { signal: AbortSignal.timeout(5000) }); + 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..fe36ccfd --- /dev/null +++ b/frontend/src/services/native/googleAuth.test.ts @@ -0,0 +1,90 @@ +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")), + ], + [ + "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"); + 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..3568a8a9 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,65 @@ 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 }); + const result = await signIn.create({ strategy: "ticket", ticket }); + if (result.status === "complete" && result.createdSessionId) { + await setActive({ session: result.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; + 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); - 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 "use_web"; + } catch (error) { + logger.warn("Google sign-in ticket was refused:", error); } - return false; + return "failed"; }