feat(auth): sync OAuth provider tokens to backend via NextAuth session

Wire the Google/Facebook OAuth callback flow end-to-end so the provider's id_token (Google) and access_token (Facebook) captured by NextAuth are forwarded to the backend in exchange for business tokens:

- Extend the NextAuth config with jwt/session callbacks that surface `account.id_token` / `account.access_token` to the client during first sign-in
- Add an `OAuthSessionSync` bridge mounted inside `RootProviders` that listens to `useSession()` and dispatches new `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` events
- Add corresponding actors in the auth XState machine that call `authRepository.googleLogin` / `facebookLogin`, persisting the backend's `LoginResponse` through the existing repository path

This keeps all authentication orchestrated by the auth state machine while preserving NextAuth's OAuth redirect UX.
This commit is contained in:
2026-06-11 12:59:23 +08:00
parent b50191ed50
commit 0c63b7dee4
6 changed files with 203 additions and 2 deletions
+5 -1
View File
@@ -25,4 +25,8 @@ export type AuthEvent =
// 社交登录 —— 状态机内部调 next-auth/react 的 signIn(provider)
| { type: "AuthGoogleLoginSubmitted"; provider: AuthProvider }
| { type: "AuthFacebookLoginSubmitted"; provider: AuthProvider }
| { type: "AuthAppleLoginSubmitted" };
| { type: "AuthAppleLoginSubmitted" }
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token
// 由 <OAuthSessionSync /> 监听 useSession() 派发
| { type: "AuthGoogleSyncSubmitted"; idToken: string }
| { type: "AuthFacebookSyncSubmitted"; accessToken: string };
+89 -1
View File
@@ -5,7 +5,8 @@
*
* 本轮迁移:所有认证登录调用(含 OAuth 社交登录)统一收口到状态机内。
* 业务层只派发事件(`AuthEmailLoginSubmitted` / `AuthGoogleLoginSubmitted` / 等),
* 状态机通过 actors 调 `authRepository`(邮箱)或 `next-auth/react.signIn`OAuth)。
* 状态机通过 actors 调 `authRepository`(邮箱 / OAuth token sync)或
* `next-auth/react.signIn`OAuth 跳转)。
*/
import { setup, fromPromise, assign } from "xstate";
import { signIn } from "next-auth/react";
@@ -77,6 +78,36 @@ const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }) => {
await signIn(input);
});
// ========== 新增:OAuth token → 后端业务 token sync actors ==========
// 由 <OAuthSessionSync /> 在 NextAuth 回调后派发(监听 useSession())。
// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。
// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。
const syncGoogleBackendActor = fromPromise<LoginStatus, { idToken: string }>(
async ({ input }) => {
const guestId = await readGuestId();
const result = await authRepository.googleLogin({
idToken: input.idToken,
guestId,
});
if (Result.isErr(result)) throw result.error;
return "google" as LoginStatus;
},
);
const syncFacebookBackendActor = fromPromise<
LoginStatus,
{ accessToken: string }
>(async ({ input }) => {
const guestId = await readGuestId();
const result = await authRepository.facebookLogin({
accessToken: input.accessToken,
guestId,
});
if (Result.isErr(result)) throw result.error;
return "facebook" as LoginStatus;
});
// ============================================================
// Machine
// ============================================================
@@ -89,6 +120,8 @@ export const authMachine = setup({
emailLogin: emailLoginActor,
emailRegisterThenLogin: emailRegisterThenLoginActor,
oauthSignIn: oauthSignInActor,
syncGoogleBackend: syncGoogleBackendActor,
syncFacebookBackend: syncFacebookBackendActor,
},
}).createMachine({
id: "auth",
@@ -125,6 +158,9 @@ export const authMachine = setup({
AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth",
AuthFacebookLoginSubmitted: "loadingOAuth",
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
AuthAppleLoginSubmitted: {
actions: assign({
errorMessage: "Apple login not implemented",
@@ -213,6 +249,58 @@ export const authMachine = setup({
},
},
// ========== 新增:OAuth token → 后端 sync 状态 ==========
syncingGoogleBackend: {
entry: assign({ errorMessage: null }),
invoke: {
src: "syncGoogleBackend",
input: ({ event }) => {
// event 来自 idle 跳转(AuthGoogleSyncSubmitted
if (event.type !== "AuthGoogleSyncSubmitted") return { idToken: "" };
return { idToken: event.idToken };
},
onDone: {
target: "success",
actions: assign({
loginStatus: ({ event }) => event.output,
errorMessage: null,
}),
},
onError: {
target: "idle",
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : String(event.error),
}),
},
},
},
syncingFacebookBackend: {
entry: assign({ errorMessage: null }),
invoke: {
src: "syncFacebookBackend",
input: ({ event }) => {
if (event.type !== "AuthFacebookSyncSubmitted") return { accessToken: "" };
return { accessToken: event.accessToken };
},
onDone: {
target: "success",
actions: assign({
loginStatus: ({ event }) => event.output,
errorMessage: null,
}),
},
onError: {
target: "idle",
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : String(event.error),
}),
},
},
},
success: {
// 终态:业务层通过 state.matches("success") 判断
},
+57
View File
@@ -0,0 +1,57 @@
"use client";
/**
* OAuth Session → Backend Sync 桥接器
*
* 监听 NextAuth session`useSession()`):
* - 有 NextAuth OAuth sessionprovider + token
* - 且 auth machine 还没有该 provider 的业务 token
* → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件
* → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token
* → 业务 token 写入本地 storage
*
* 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state
* machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后:
* <AuthProvider> ← 提供 useAuthState / useAuthDispatch
* <OAuthSessionSync /> ← 用上面两个 hook
* </AuthProvider>
*/
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import { useAuthDispatch, useAuthState } from "./auth-context";
export function OAuthSessionSync() {
const { data: session, status } = useSession();
const dispatch = useAuthDispatch();
const { loginStatus } = useAuthState();
useEffect(() => {
// 1) NextAuth 还没就绪
if (status !== "authenticated" || !session) return;
// 2) 没有 OAuth provider(可能是 email / 其它)
if (!session.provider) return;
// 3) 已经有该 provider 的业务 token(防止重复 sync 刷后端 user 表)
if (loginStatus === session.provider) return;
// 4) Google:派发 idToken sync 事件
if (session.provider === "google") {
if (!session.idToken) return;
dispatch({ type: "AuthGoogleSyncSubmitted", idToken: session.idToken });
return;
}
// 5) Facebook:派发 accessToken sync 事件
if (session.provider === "facebook") {
if (!session.accessToken) return;
dispatch({
type: "AuthFacebookSyncSubmitted",
accessToken: session.accessToken,
});
}
}, [status, session, loginStatus, dispatch]);
// 桥接器无 UI
return null;
}