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 { createActor, waitFor } from "xstate";
|
||||||
|
|
||||||
|
import { OAUTH_LAUNCH_TIMEOUT_MS } from "../machine/oauth-flow";
|
||||||
import { createTestAuthMachine } from "./auth-machine.test-utils";
|
import { createTestAuthMachine } from "./auth-machine.test-utils";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("auth OAuth flow", () => {
|
describe("auth OAuth flow", () => {
|
||||||
it("applies Google backend sync status", async () => {
|
it("applies Google backend sync status", async () => {
|
||||||
const actor = createActor(createTestAuthMachine()).start();
|
const actor = createActor(createTestAuthMachine()).start();
|
||||||
@@ -23,6 +28,56 @@ describe("auth OAuth flow", () => {
|
|||||||
actor.stop();
|
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 () => {
|
it("returns to idle with an error when OAuth launch fails", async () => {
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestAuthMachine({ oauthSignInError: new Error("oauth failed") }),
|
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 oauthMachineSetup = credentialsMachineSetup;
|
||||||
|
|
||||||
|
export const OAUTH_LAUNCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const oauthSyncDoneSetup =
|
const oauthSyncDoneSetup =
|
||||||
createAuthActorActionSetup<DoneActorEvent<LoginStatus>>();
|
createAuthActorActionSetup<DoneActorEvent<LoginStatus>>();
|
||||||
const oauthErrorSetup = createAuthActorActionSetup<ErrorActorEvent>();
|
const oauthErrorSetup = createAuthActorActionSetup<ErrorActorEvent>();
|
||||||
@@ -28,6 +30,10 @@ const applyOAuthLaunchErrorAction = oauthErrorSetup.assign(({ event }) => ({
|
|||||||
errorMessage: toAuthErrorMessage(event.error),
|
errorMessage: toAuthErrorMessage(event.error),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const applyOAuthLaunchTimeoutAction = oauthMachineSetup.assign({
|
||||||
|
errorMessage: "Sign-in did not open. Please try again.",
|
||||||
|
});
|
||||||
|
|
||||||
export const loadingOAuthState = oauthMachineSetup.createStateConfig({
|
export const loadingOAuthState = oauthMachineSetup.createStateConfig({
|
||||||
entry: "clearAuthError",
|
entry: "clearAuthError",
|
||||||
invoke: {
|
invoke: {
|
||||||
@@ -37,12 +43,17 @@ export const loadingOAuthState = oauthMachineSetup.createStateConfig({
|
|||||||
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
|
if (event.type === "AuthFacebookLoginSubmitted") return event.provider;
|
||||||
return "google" as AuthProvider;
|
return "google" as AuthProvider;
|
||||||
},
|
},
|
||||||
onDone: { target: "idle" },
|
|
||||||
onError: {
|
onError: {
|
||||||
target: "idle",
|
target: "idle",
|
||||||
actions: applyOAuthLaunchErrorAction,
|
actions: applyOAuthLaunchErrorAction,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
after: {
|
||||||
|
[OAUTH_LAUNCH_TIMEOUT_MS]: {
|
||||||
|
target: "idle",
|
||||||
|
actions: applyOAuthLaunchTimeoutAction,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const syncingGoogleBackendState = oauthMachineSetup.createStateConfig({
|
export const syncingGoogleBackendState = oauthMachineSetup.createStateConfig({
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ const idleState = authMachineSetup.createStateConfig({
|
|||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
AuthFacebookLoginSubmitted: "loadingOAuth",
|
AuthFacebookLoginSubmitted: "loadingOAuth",
|
||||||
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
|
||||||
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +129,10 @@ const loggingOutState = authMachineSetup.createStateConfig({
|
|||||||
|
|
||||||
export const authRootStateConfig = authMachineSetup.createStateConfig({
|
export const authRootStateConfig = authMachineSetup.createStateConfig({
|
||||||
initial: "idle",
|
initial: "idle",
|
||||||
|
on: {
|
||||||
|
AuthGoogleSyncSubmitted: ".syncingGoogleBackend",
|
||||||
|
AuthFacebookSyncSubmitted: ".syncingFacebookBackend",
|
||||||
|
},
|
||||||
states: {
|
states: {
|
||||||
idle: idleState,
|
idle: idleState,
|
||||||
initializing: initializingState,
|
initializing: initializingState,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件
|
* → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件
|
||||||
* → auth machine 调 `AuthRepository.googleLogin/facebookLogin` 换业务 token
|
* → auth machine 调 `AuthRepository.googleLogin/facebookLogin` 换业务 token
|
||||||
* → 业务 token 写入本地 storage
|
* → 业务 token 写入本地 storage
|
||||||
* → **清掉 NextAuth session**(`signOut({ redirect: false })`):
|
* → 业务后端同步成功或失败后,再清掉 NextAuth session:
|
||||||
* idToken / accessToken 已交给后端换业务 token,浏览器侧不再需要保留 OAuth 凭证
|
* idToken / accessToken 已交给后端换业务 token,浏览器侧不再需要保留 OAuth 凭证
|
||||||
* 避免凭证在 cookie / sessionStorage 里持续暴露
|
* 避免凭证在 cookie / sessionStorage 里持续暴露
|
||||||
*
|
*
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
* <OAuthSessionSync /> ← 用上面两个 hook
|
* <OAuthSessionSync /> ← 用上面两个 hook
|
||||||
* </AuthProvider>
|
* </AuthProvider>
|
||||||
*/
|
*/
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
import { useAuthDispatch, useAuthState } from "./auth-context";
|
import { useAuthDispatch, useAuthState } from "./auth-context";
|
||||||
@@ -26,38 +26,61 @@ import { useAuthDispatch, useAuthState } from "./auth-context";
|
|||||||
export function OAuthSessionSync() {
|
export function OAuthSessionSync() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const dispatch = useAuthDispatch();
|
const dispatch = useAuthDispatch();
|
||||||
const { loginStatus } = useAuthState();
|
const { errorMessage, isLoading, loginStatus } = useAuthState();
|
||||||
|
const pendingProviderRef = useRef<"google" | "facebook" | null>(null);
|
||||||
|
const isClearingSessionRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 1) NextAuth 还没就绪
|
// 1) NextAuth 还没就绪
|
||||||
if (status !== "authenticated" || !session) return;
|
if (status !== "authenticated" || !session) {
|
||||||
|
pendingProviderRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2) 没有 OAuth provider(可能是 email / 其它)
|
// 2) 没有 OAuth provider(可能是 email / 其它)
|
||||||
if (!session.provider) return;
|
if (!session.provider) return;
|
||||||
|
|
||||||
// 3) 已经有该 provider 的业务 token(防止重复 sync 刷后端 user 表)
|
const clearOAuthSession = () => {
|
||||||
if (loginStatus === session.provider) return;
|
if (isClearingSessionRef.current) return;
|
||||||
|
isClearingSessionRef.current = true;
|
||||||
|
|
||||||
// 4) Google:派发 idToken sync 事件 → 清 NextAuth session
|
const reset = () => {
|
||||||
if (session.provider === "google") {
|
pendingProviderRef.current = null;
|
||||||
if (!session.idToken) return;
|
isClearingSessionRef.current = false;
|
||||||
dispatch({ type: "AuthGoogleSyncSubmitted", idToken: session.idToken });
|
};
|
||||||
// OAuth token 已交给后端 —— 清掉 NextAuth session(fire-and-forget)
|
void signOut({ redirect: false }).then(reset, reset);
|
||||||
// 清完后 useSession 返 null / unauthenticated → 下次 effect 早 return,不会重入
|
};
|
||||||
void signOut({ redirect: false });
|
|
||||||
|
// 3) 业务同步已成功,现在才能安全清理 OAuth session。
|
||||||
|
if (loginStatus === session.provider) {
|
||||||
|
clearOAuthSession();
|
||||||
return;
|
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.provider === "facebook") {
|
||||||
if (!session.accessToken) return;
|
if (!session.accessToken) return;
|
||||||
|
pendingProviderRef.current = "facebook";
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "AuthFacebookSyncSubmitted",
|
type: "AuthFacebookSyncSubmitted",
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
});
|
});
|
||||||
void signOut({ redirect: false });
|
|
||||||
}
|
}
|
||||||
}, [status, session, loginStatus, dispatch]);
|
}, [dispatch, errorMessage, isLoading, loginStatus, session, status]);
|
||||||
|
|
||||||
// 桥接器无 UI
|
// 桥接器无 UI
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user