feat(auth): enhance OAuth flow with Facebook sync and timeout handling
This commit is contained in:
@@ -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 />);
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
Reference in New Issue
Block a user