diff --git a/src/lib/auth/nextauth.ts b/src/lib/auth/nextauth.ts index 90a9e947..d2dcfcce 100644 --- a/src/lib/auth/nextauth.ts +++ b/src/lib/auth/nextauth.ts @@ -33,6 +33,28 @@ const handler = NextAuth({ pages: { signIn: "/auth", }, + // 把 OAuth provider 的 token 塞进 NextAuth session,供前端取用: + // - Google: account.id_token → 调后端 /api/auth/google-login 换业务 token + // - Facebook: account.access_token → 调后端 /api/auth/facebook-login + // 仅在**首次 sign-in** 时把 `account` 注入到 JWT;后续请求 account=undefined + // (由 NextAuth 控制),所以下面的 `if (account)` 守卫是必要的。 + callbacks: { + async jwt({ token, account }) { + if (account) { + token.provider = account.provider; + token.idToken = account.id_token; + token.accessToken = account.access_token; + } + return token; + }, + async session({ session, token }) { + // 暴露给 `useSession()` —— 仅 client-side 派发 sync 事件时使用,不落 localStorage + session.provider = (token.provider as "google" | "facebook" | undefined) ?? undefined; + session.idToken = (token.idToken as string | undefined) ?? undefined; + session.accessToken = (token.accessToken as string | undefined) ?? undefined; + return session; + }, + }, // 显式允许从请求头(X-Forwarded-Host)推断 host;消除 v4 [NEXTAUTH_URL] 警告 // 生产部署在反向代理后(nginx / cloudflare)必须开 // @ts-expect-error: `trustHost` 不在 v4.24.x 的 AuthOptions 类型里,但运行时支持 diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index 0df79b9d..3dbbfb93 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -5,6 +5,7 @@ * * 把所有功能 Provider 串起来: * AuthProvider → UserProvider → SidebarProvider → ChatProvider + * + OAuthSessionSync 桥接器(监听 NextAuth session → auth machine) * * 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可, * 无需在每个页面单独包裹。 @@ -13,6 +14,7 @@ import type { ReactNode } from "react"; import { AuthProvider } from "@/stores/auth/auth-context"; +import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; import { ChatProvider } from "@/stores/chat/chat-context"; import { SidebarProvider } from "@/stores/sidebar/sidebar-context"; import { UserProvider } from "@/stores/user/user-context"; @@ -24,6 +26,9 @@ export interface RootProvidersProps { export function RootProviders({ children }: RootProvidersProps) { return ( + {/* OAuthSessionSync 必须放在 AuthProvider 内部(依赖 useAuthDispatch/useAuthState), + * 也必须在 ChatProvider 外部(ChatProvider 内部组件可能在 sync 完成前就需要登录态) */} + diff --git a/src/stores/auth/auth-events.ts b/src/stores/auth/auth-events.ts index 8bfa4d11..6bfc0531 100644 --- a/src/stores/auth/auth-events.ts +++ b/src/stores/auth/auth-events.ts @@ -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 + // 由 监听 useSession() 派发 + | { type: "AuthGoogleSyncSubmitted"; idToken: string } + | { type: "AuthFacebookSyncSubmitted"; accessToken: string }; diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index 65aba59c..095e5c40 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -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(async ({ input }) => { await signIn(input); }); +// ========== 新增:OAuth token → 后端业务 token sync actors ========== +// 由 在 NextAuth 回调后派发(监听 useSession())。 +// 用 OAuth provider 的 idToken / accessToken 调后端换业务 token + user。 +// 后端返回的 LoginResponse 已经被 authRepository._saveLoginData 持久化。 + +const syncGoogleBackendActor = fromPromise( + 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 入口 —— 由 派发 + 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") 判断 }, diff --git a/src/stores/auth/oauth-session-sync.tsx b/src/stores/auth/oauth-session-sync.tsx new file mode 100644 index 00000000..8594f07d --- /dev/null +++ b/src/stores/auth/oauth-session-sync.tsx @@ -0,0 +1,57 @@ +"use client"; +/** + * OAuth Session → Backend Sync 桥接器 + * + * 监听 NextAuth session(`useSession()`): + * - 有 NextAuth OAuth session(provider + token) + * - 且 auth machine 还没有该 provider 的业务 token + * → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件 + * → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token + * → 业务 token 写入本地 storage + * + * 此组件**无可见 UI**(返回 null),仅作为 NextAuth session 与 auth state + * machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后: + * ← 提供 useAuthState / useAuthDispatch + * ← 用上面两个 hook + * + */ +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; +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 00000000..549e0c26 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,25 @@ +import "next-auth"; + +/** + * 扩展 next-auth v4 的 Session 类型 + * + * `src/lib/auth/nextauth.ts` 的 jwt/session callbacks 把 + * - OAuth provider 名("google" | "facebook") + * - Google id_token + * - Facebook access_token + * 塞进了 session 对象。 + * + * 这里通过 TS 声明合并暴露给 `useSession()` 消费者。 + * + * 注:声明文件全局生效(`globals.d.ts` 风格)—— 不用手动 import。 + */ +declare module "next-auth" { + interface Session { + /** OAuth provider 名(首次 sign-in 时由 jwt callback 注入) */ + provider?: "google" | "facebook"; + /** Google OAuth id_token —— 调后端 /api/auth/google-login 换业务 token */ + idToken?: string; + /** Facebook OAuth access_token —— 调后端 /api/auth/facebook-login 换业务 token */ + accessToken?: string; + } +}