feat(auth): enhance OAuth flow with Facebook sync and timeout handling

This commit is contained in:
2026-07-15 15:36:08 +08:00
parent 1cbb306cee
commit 2265a98e76
5 changed files with 206 additions and 20 deletions
@@ -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") }),
@@ -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<typeof createRoot> | 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<void> {
if (!container) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
}
await act(async () => {
root?.render(<OAuthSessionSync />);
});
}
+12 -1
View File
@@ -9,6 +9,8 @@ import { createAuthActorActionSetup } from "./setup";
export const oauthMachineSetup = credentialsMachineSetup;
export const OAUTH_LAUNCH_TIMEOUT_MS = 10_000;
const oauthSyncDoneSetup =
createAuthActorActionSetup<DoneActorEvent<LoginStatus>>();
const oauthErrorSetup = createAuthActorActionSetup<ErrorActorEvent>();
@@ -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({
+4 -2
View File
@@ -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,
+39 -16
View File
@@ -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 @@
* <OAuthSessionSync /> ← 用上面两个 hook
* </AuthProvider>
*/
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 sessionfire-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;