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:
@@ -33,6 +33,28 @@ const handler = NextAuth({
|
|||||||
pages: {
|
pages: {
|
||||||
signIn: "/auth",
|
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] 警告
|
// 显式允许从请求头(X-Forwarded-Host)推断 host;消除 v4 [NEXTAUTH_URL] 警告
|
||||||
// 生产部署在反向代理后(nginx / cloudflare)必须开
|
// 生产部署在反向代理后(nginx / cloudflare)必须开
|
||||||
// @ts-expect-error: `trustHost` 不在 v4.24.x 的 AuthOptions 类型里,但运行时支持
|
// @ts-expect-error: `trustHost` 不在 v4.24.x 的 AuthOptions 类型里,但运行时支持
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* 把所有功能 Provider 串起来:
|
* 把所有功能 Provider 串起来:
|
||||||
* AuthProvider → UserProvider → SidebarProvider → ChatProvider
|
* AuthProvider → UserProvider → SidebarProvider → ChatProvider
|
||||||
|
* + OAuthSessionSync 桥接器(监听 NextAuth session → auth machine)
|
||||||
*
|
*
|
||||||
* 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可,
|
* 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可,
|
||||||
* 无需在每个页面单独包裹。
|
* 无需在每个页面单独包裹。
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { AuthProvider } from "@/stores/auth/auth-context";
|
import { AuthProvider } from "@/stores/auth/auth-context";
|
||||||
|
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
|
||||||
import { ChatProvider } from "@/stores/chat/chat-context";
|
import { ChatProvider } from "@/stores/chat/chat-context";
|
||||||
import { SidebarProvider } from "@/stores/sidebar/sidebar-context";
|
import { SidebarProvider } from "@/stores/sidebar/sidebar-context";
|
||||||
import { UserProvider } from "@/stores/user/user-context";
|
import { UserProvider } from "@/stores/user/user-context";
|
||||||
@@ -24,6 +26,9 @@ export interface RootProvidersProps {
|
|||||||
export function RootProviders({ children }: RootProvidersProps) {
|
export function RootProviders({ children }: RootProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
{/* OAuthSessionSync 必须放在 AuthProvider 内部(依赖 useAuthDispatch/useAuthState),
|
||||||
|
* 也必须在 ChatProvider 外部(ChatProvider 内部组件可能在 sync 完成前就需要登录态) */}
|
||||||
|
<OAuthSessionSync />
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ export type AuthEvent =
|
|||||||
// 社交登录 —— 状态机内部调 next-auth/react 的 signIn(provider)
|
// 社交登录 —— 状态机内部调 next-auth/react 的 signIn(provider)
|
||||||
| { type: "AuthGoogleLoginSubmitted"; provider: AuthProvider }
|
| { type: "AuthGoogleLoginSubmitted"; provider: AuthProvider }
|
||||||
| { type: "AuthFacebookLoginSubmitted"; 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 };
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
*
|
*
|
||||||
* 本轮迁移:所有认证登录调用(含 OAuth 社交登录)统一收口到状态机内。
|
* 本轮迁移:所有认证登录调用(含 OAuth 社交登录)统一收口到状态机内。
|
||||||
* 业务层只派发事件(`AuthEmailLoginSubmitted` / `AuthGoogleLoginSubmitted` / 等),
|
* 业务层只派发事件(`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 { setup, fromPromise, assign } from "xstate";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
@@ -77,6 +78,36 @@ const oauthSignInActor = fromPromise<void, AuthProvider>(async ({ input }) => {
|
|||||||
await signIn(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
|
// Machine
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -89,6 +120,8 @@ export const authMachine = setup({
|
|||||||
emailLogin: emailLoginActor,
|
emailLogin: emailLoginActor,
|
||||||
emailRegisterThenLogin: emailRegisterThenLoginActor,
|
emailRegisterThenLogin: emailRegisterThenLoginActor,
|
||||||
oauthSignIn: oauthSignInActor,
|
oauthSignIn: oauthSignInActor,
|
||||||
|
syncGoogleBackend: syncGoogleBackendActor,
|
||||||
|
syncFacebookBackend: syncFacebookBackendActor,
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: "auth",
|
id: "auth",
|
||||||
@@ -125,6 +158,9 @@ export const authMachine = setup({
|
|||||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||||
AuthGoogleLoginSubmitted: "loadingOAuth",
|
AuthGoogleLoginSubmitted: "loadingOAuth",
|
||||||
AuthFacebookLoginSubmitted: "loadingOAuth",
|
AuthFacebookLoginSubmitted: "loadingOAuth",
|
||||||
|
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
|
||||||
|
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
|
||||||
|
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
|
||||||
AuthAppleLoginSubmitted: {
|
AuthAppleLoginSubmitted: {
|
||||||
actions: assign({
|
actions: assign({
|
||||||
errorMessage: "Apple login not implemented",
|
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: {
|
success: {
|
||||||
// 终态:业务层通过 state.matches("success") 判断
|
// 终态:业务层通过 state.matches("success") 判断
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 之后:
|
||||||
|
* <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;
|
||||||
|
}
|
||||||
Vendored
+25
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user