From 2265a98e76b78fee5ac890a786d83de01135b037 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 15:36:08 +0800 Subject: [PATCH] feat(auth): enhance OAuth flow with Facebook sync and timeout handling --- .../auth/__tests__/auth-oauth-flow.test.ts | 57 ++++++++++- .../__tests__/oauth-session-sync.test.tsx | 95 +++++++++++++++++++ src/stores/auth/machine/oauth-flow.ts | 13 ++- src/stores/auth/machine/session-flow.ts | 6 +- src/stores/auth/oauth-session-sync.tsx | 55 +++++++---- 5 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 src/stores/auth/__tests__/oauth-session-sync.test.tsx diff --git a/src/stores/auth/__tests__/auth-oauth-flow.test.ts b/src/stores/auth/__tests__/auth-oauth-flow.test.ts index b7f6132d..ea25aa37 100644 --- a/src/stores/auth/__tests__/auth-oauth-flow.test.ts +++ b/src/stores/auth/__tests__/auth-oauth-flow.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createActor, waitFor } from "xstate"; +import { OAUTH_LAUNCH_TIMEOUT_MS } from "../machine/oauth-flow"; import { createTestAuthMachine } from "./auth-machine.test-utils"; +afterEach(() => { + vi.useRealTimers(); +}); + describe("auth OAuth flow", () => { it("applies Google backend sync status", async () => { const actor = createActor(createTestAuthMachine()).start(); @@ -23,6 +28,56 @@ describe("auth OAuth flow", () => { actor.stop(); }); + it("keeps loading after OAuth launch until the callback arrives", async () => { + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ type: "AuthFacebookLoginSubmitted", provider: "facebook" }); + await waitFor(actor, (snapshot) => snapshot.matches("loadingOAuth")); + await Promise.resolve(); + + expect(actor.getSnapshot().matches("loadingOAuth")).toBe(true); + + actor.send({ + type: "AuthFacebookSyncSubmitted", + accessToken: "facebook-token", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("facebook"); + actor.stop(); + }); + + it("accepts an OAuth callback while auth initialization is running", async () => { + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ type: "AuthInit" }); + expect(actor.getSnapshot().matches("initializing")).toBe(true); + actor.send({ + type: "AuthFacebookSyncSubmitted", + accessToken: "facebook-token", + }); + await waitFor(actor, (snapshot) => snapshot.matches("idle")); + + expect(actor.getSnapshot().context.loginStatus).toBe("facebook"); + actor.stop(); + }); + + it("returns to idle with an error when OAuth navigation times out", async () => { + vi.useFakeTimers(); + const actor = createActor(createTestAuthMachine()).start(); + + actor.send({ type: "AuthFacebookLoginSubmitted", provider: "facebook" }); + expect(actor.getSnapshot().matches("loadingOAuth")).toBe(true); + + await vi.advanceTimersByTimeAsync(OAUTH_LAUNCH_TIMEOUT_MS); + + expect(actor.getSnapshot().matches("idle")).toBe(true); + expect(actor.getSnapshot().context.errorMessage).toBe( + "Sign-in did not open. Please try again.", + ); + actor.stop(); + }); + it("returns to idle with an error when OAuth launch fails", async () => { const actor = createActor( createTestAuthMachine({ oauthSignInError: new Error("oauth failed") }), diff --git a/src/stores/auth/__tests__/oauth-session-sync.test.tsx b/src/stores/auth/__tests__/oauth-session-sync.test.tsx new file mode 100644 index 00000000..ff372a70 --- /dev/null +++ b/src/stores/auth/__tests__/oauth-session-sync.test.tsx @@ -0,0 +1,95 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authState: { + errorMessage: null as string | null, + isLoading: false, + loginStatus: "notLoggedIn", + }, + dispatch: vi.fn(), + sessionState: { + data: { + provider: "facebook", + accessToken: "facebook-token", + }, + status: "authenticated", + }, + signOut: vi.fn(async () => undefined), +})); + +vi.mock("next-auth/react", () => ({ + signOut: mocks.signOut, + useSession: () => mocks.sessionState, +})); + +vi.mock("../auth-context", () => ({ + useAuthDispatch: () => mocks.dispatch, + useAuthState: () => mocks.authState, +})); + +import { OAuthSessionSync } from "../oauth-session-sync"; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; + mocks.authState.errorMessage = null; + mocks.authState.isLoading = false; + mocks.authState.loginStatus = "notLoggedIn"; + mocks.dispatch.mockReset(); + mocks.signOut.mockReset(); + mocks.signOut.mockResolvedValue(undefined); +}); + +describe("OAuthSessionSync", () => { + it("keeps the NextAuth session until backend sync succeeds", async () => { + await renderSync(); + + expect(mocks.dispatch).toHaveBeenCalledWith({ + type: "AuthFacebookSyncSubmitted", + accessToken: "facebook-token", + }); + expect(mocks.signOut).not.toHaveBeenCalled(); + + mocks.authState.isLoading = true; + await renderSync(); + expect(mocks.dispatch).toHaveBeenCalledOnce(); + expect(mocks.signOut).not.toHaveBeenCalled(); + + mocks.authState.isLoading = false; + mocks.authState.loginStatus = "facebook"; + await renderSync(); + expect(mocks.signOut).toHaveBeenCalledOnce(); + }); + + it("clears the NextAuth session after backend sync fails", async () => { + await renderSync(); + + mocks.authState.isLoading = true; + await renderSync(); + mocks.authState.isLoading = false; + mocks.authState.errorMessage = "Backend sync failed"; + await renderSync(); + + expect(mocks.dispatch).toHaveBeenCalledOnce(); + expect(mocks.signOut).toHaveBeenCalledOnce(); + }); +}); + +async function renderSync(): Promise { + if (!container) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + } + + await act(async () => { + root?.render(); + }); +} diff --git a/src/stores/auth/machine/oauth-flow.ts b/src/stores/auth/machine/oauth-flow.ts index 3185e8b9..61177fdf 100644 --- a/src/stores/auth/machine/oauth-flow.ts +++ b/src/stores/auth/machine/oauth-flow.ts @@ -9,6 +9,8 @@ import { createAuthActorActionSetup } from "./setup"; export const oauthMachineSetup = credentialsMachineSetup; +export const OAUTH_LAUNCH_TIMEOUT_MS = 10_000; + const oauthSyncDoneSetup = createAuthActorActionSetup>(); const oauthErrorSetup = createAuthActorActionSetup(); @@ -28,6 +30,10 @@ const applyOAuthLaunchErrorAction = oauthErrorSetup.assign(({ event }) => ({ errorMessage: toAuthErrorMessage(event.error), })); +const applyOAuthLaunchTimeoutAction = oauthMachineSetup.assign({ + errorMessage: "Sign-in did not open. Please try again.", +}); + export const loadingOAuthState = oauthMachineSetup.createStateConfig({ entry: "clearAuthError", invoke: { @@ -37,12 +43,17 @@ export const loadingOAuthState = oauthMachineSetup.createStateConfig({ if (event.type === "AuthFacebookLoginSubmitted") return event.provider; return "google" as AuthProvider; }, - onDone: { target: "idle" }, onError: { target: "idle", actions: applyOAuthLaunchErrorAction, }, }, + after: { + [OAUTH_LAUNCH_TIMEOUT_MS]: { + target: "idle", + actions: applyOAuthLaunchTimeoutAction, + }, + }, }); export const syncingGoogleBackendState = oauthMachineSetup.createStateConfig({ diff --git a/src/stores/auth/machine/session-flow.ts b/src/stores/auth/machine/session-flow.ts index 9bd2e5bf..d400f9b1 100644 --- a/src/stores/auth/machine/session-flow.ts +++ b/src/stores/auth/machine/session-flow.ts @@ -67,8 +67,6 @@ const idleState = authMachineSetup.createStateConfig({ AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthGoogleLoginSubmitted: "loadingOAuth", AuthFacebookLoginSubmitted: "loadingOAuth", - AuthGoogleSyncSubmitted: "syncingGoogleBackend", - AuthFacebookSyncSubmitted: "syncingFacebookBackend", }, }); @@ -131,6 +129,10 @@ const loggingOutState = authMachineSetup.createStateConfig({ export const authRootStateConfig = authMachineSetup.createStateConfig({ initial: "idle", + on: { + AuthGoogleSyncSubmitted: ".syncingGoogleBackend", + AuthFacebookSyncSubmitted: ".syncingFacebookBackend", + }, states: { idle: idleState, initializing: initializingState, diff --git a/src/stores/auth/oauth-session-sync.tsx b/src/stores/auth/oauth-session-sync.tsx index 38d54994..fc3e36f3 100644 --- a/src/stores/auth/oauth-session-sync.tsx +++ b/src/stores/auth/oauth-session-sync.tsx @@ -8,7 +8,7 @@ * → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件 * → auth machine 调 `AuthRepository.googleLogin/facebookLogin` 换业务 token * → 业务 token 写入本地 storage - * → **清掉 NextAuth session**(`signOut({ redirect: false })`): + * → 业务后端同步成功或失败后,再清掉 NextAuth session: * idToken / accessToken 已交给后端换业务 token,浏览器侧不再需要保留 OAuth 凭证 * 避免凭证在 cookie / sessionStorage 里持续暴露 * @@ -18,7 +18,7 @@ * ← 用上面两个 hook * */ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { signOut, useSession } from "next-auth/react"; import { useAuthDispatch, useAuthState } from "./auth-context"; @@ -26,38 +26,61 @@ import { useAuthDispatch, useAuthState } from "./auth-context"; export function OAuthSessionSync() { const { data: session, status } = useSession(); const dispatch = useAuthDispatch(); - const { loginStatus } = useAuthState(); + const { errorMessage, isLoading, loginStatus } = useAuthState(); + const pendingProviderRef = useRef<"google" | "facebook" | null>(null); + const isClearingSessionRef = useRef(false); useEffect(() => { // 1) NextAuth 还没就绪 - if (status !== "authenticated" || !session) return; + if (status !== "authenticated" || !session) { + pendingProviderRef.current = null; + return; + } // 2) 没有 OAuth provider(可能是 email / 其它) if (!session.provider) return; - // 3) 已经有该 provider 的业务 token(防止重复 sync 刷后端 user 表) - if (loginStatus === session.provider) return; + const clearOAuthSession = () => { + if (isClearingSessionRef.current) return; + isClearingSessionRef.current = true; - // 4) Google:派发 idToken sync 事件 → 清 NextAuth session - if (session.provider === "google") { - if (!session.idToken) return; - dispatch({ type: "AuthGoogleSyncSubmitted", idToken: session.idToken }); - // OAuth token 已交给后端 —— 清掉 NextAuth session(fire-and-forget) - // 清完后 useSession 返 null / unauthenticated → 下次 effect 早 return,不会重入 - void signOut({ redirect: false }); + const reset = () => { + pendingProviderRef.current = null; + isClearingSessionRef.current = false; + }; + void signOut({ redirect: false }).then(reset, reset); + }; + + // 3) 业务同步已成功,现在才能安全清理 OAuth session。 + if (loginStatus === session.provider) { + clearOAuthSession(); return; } - // 5) Facebook:派发 accessToken sync 事件 → 清 NextAuth session + // 4) 同一 provider 已派发过;失败后清理 session,允许用户重新点击。 + if (pendingProviderRef.current === session.provider) { + if (!isLoading && errorMessage) clearOAuthSession(); + return; + } + + // 5) Google:派发 idToken sync 事件。 + if (session.provider === "google") { + if (!session.idToken) return; + pendingProviderRef.current = "google"; + dispatch({ type: "AuthGoogleSyncSubmitted", idToken: session.idToken }); + return; + } + + // 6) Facebook:派发 accessToken sync 事件。 if (session.provider === "facebook") { if (!session.accessToken) return; + pendingProviderRef.current = "facebook"; dispatch({ type: "AuthFacebookSyncSubmitted", accessToken: session.accessToken, }); - void signOut({ redirect: false }); } - }, [status, session, loginStatus, dispatch]); + }, [dispatch, errorMessage, isLoading, loginStatus, session, status]); // 桥接器无 UI return null;