feat: refactor the auth condition in Next Auth (#5866)

This upgrade includes two changes:

For users deploying with Vercel using next-auth, it is necessary to add the environment variable `NEXT_PUBLIC_ENABLE_NEXT_AUTH=1` to ensure Next Auth is enabled; other users are not affected.
For users using clerk in self-built images, it is necessary to additionally configure `NEXT_PUBLIC_ENABLE_NEXT_AUTH=0` to disable Next Auth

Other standard deployment scenarios (using Clerk in Vercel and using next-auth in Docker) are not affected

For More detail, refer to https://github.com/lobehub/lobe-chat/issues/5804

本次升级存在两个变更:

- 针对使用 Vercel 部署中使用 next-auth 的用户,需要额外添加 `NEXT_PUBLIC_ENABLE_NEXT_AUTH=1` 环境变量来确保开启 Next Auth
- 针对使用自构建镜像中使用 clerk 的用户,需要额外配置 `NEXT_PUBLIC_ENABLE_NEXT_AUTH=0` 环境变量来关闭 Next Auth

其他标准部署场景(Vercel 中使用 Clerk 与 Docker 中使用 next-auth )不受影响

变更详情原因查看 https://github.com/lobehub/lobe-chat/issues/5804
This commit is contained in:
Arvin Xu
2025-02-08 10:25:28 +08:00
committed by GitHub
parent a1149301a3
commit e529108ff6
26 changed files with 58 additions and 2266 deletions

View File

@@ -190,6 +190,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# NextAuth related configurations
# NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
# NEXT_AUTH_SECRET=
# Auth0 configurations

View File

@@ -38,6 +38,7 @@ FROM base AS builder
ARG USE_CN_MIRROR
ARG NEXT_PUBLIC_BASE_PATH
ARG NEXT_PUBLIC_SERVICE_MODE
ARG NEXT_PUBLIC_ENABLE_NEXT_AUTH
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_ANALYTICS_POSTHOG
ARG NEXT_PUBLIC_POSTHOG_HOST
@@ -49,7 +50,7 @@ ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}"
ENV NEXT_PUBLIC_SERVICE_MODE="${NEXT_PUBLIC_SERVICE_MODE:-server}" \
NEXT_PUBLIC_ENABLE_NEXT_AUTH="1" \
NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-1}" \
APP_URL="http://app.com" \
DATABASE_DRIVER="node" \
DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" \

View File

@@ -179,6 +179,7 @@ const nextConfig: NextConfig = {
],
// when external packages in dev mode with turbopack, this config will lead to bundle error
serverExternalPackages: isProd ? ['@electric-sql/pglite'] : undefined,
transpilePackages: ['pdfjs-dist', 'mermaid'],
webpack(config) {

View File

@@ -49,8 +49,9 @@ afterEach(() => {
describe('UserBanner', () => {
it('should render UserInfo and DataStatistics when auth is disabled', () => {
act(() => {
useUserStore.setState({ isSignedIn: false, enableAuth: () => false });
useUserStore.setState({ isSignedIn: false });
});
enableAuth = false;
render(<UserBanner />);
@@ -75,7 +76,7 @@ describe('UserBanner', () => {
it('should render UserLoginOrSignup when user is not logged in with auth enabled', () => {
act(() => {
useUserStore.setState({ isSignedIn: false, enableAuth: () => true });
useUserStore.setState({ isSignedIn: false });
});
enableClerk = true;

View File

@@ -45,12 +45,10 @@ afterEach(() => {
enableClerk = true;
});
// 目前对 enableAuth 的判定是在 useUserStore 中,所以需要 mock useUserStore
// 类型定义: enableAuth: () => boolean
describe('useCategory', () => {
it('should return correct items when the user is logged in with authentication', () => {
act(() => {
useUserStore.setState({ isSignedIn: true, enableAuth: () => true });
useUserStore.setState({ isSignedIn: true });
});
enableAuth = true;
enableClerk = false;
@@ -70,8 +68,9 @@ describe('useCategory', () => {
it('should return correct items when the user is not logged in', () => {
act(() => {
useUserStore.setState({ isSignedIn: false, enableAuth: () => true });
useUserStore.setState({ isSignedIn: false });
});
enableAuth = true;
const { result } = renderHook(() => useCategory(), { wrapper });
@@ -88,9 +87,10 @@ describe('useCategory', () => {
it('should handle settings for non-authenticated users', () => {
act(() => {
useUserStore.setState({ isSignedIn: false, enableAuth: () => false });
useUserStore.setState({ isSignedIn: false });
});
enableClerk = false;
enableAuth = false;
const { result } = renderHook(() => useCategory(), { wrapper });

View File

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { enableAuth, enableNextAuth } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import DataStatistics from '@/features/User/DataStatistics';
import UserInfo from '@/features/User/UserInfo';
@@ -15,11 +16,7 @@ import { authSelectors } from '@/store/user/selectors';
const UserBanner = memo(() => {
const router = useRouter();
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
const [enableAuth, signIn, enabledNextAuth] = useUserStore((s) => [
authSelectors.enabledAuth(s),
s.openLogin,
authSelectors.enabledNextAuth(s),
]);
const [signIn] = useUserStore((s) => [s.openLogin]);
return (
<Flexbox gap={12} paddingBlock={8}>
@@ -38,7 +35,7 @@ const UserBanner = memo(() => {
<UserLoginOrSignup
onClick={() => {
// If use NextAuth, call openLogin method directly
if (enabledNextAuth) {
if (enableNextAuth) {
signIn();
return;
}

View File

@@ -12,6 +12,7 @@ import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { CellProps } from '@/components/Cell';
import { enableAuth } from '@/const/auth';
import { LOBE_CHAT_CLOUD } from '@/const/branding';
import { DOCUMENTS, FEEDBACK, OFFICIAL_URL, UTM_SOURCE } from '@/const/url';
import { isServerMode } from '@/const/version';
@@ -27,10 +28,9 @@ export const useCategory = () => {
const { canInstall, install } = usePWAInstall();
const { t } = useTranslation(['common', 'setting', 'auth']);
const { showCloudPromotion, hideDocs } = useServerConfigStore(featureFlagsSelectors);
const [isLogin, isLoginWithAuth, enableAuth] = useUserStore((s) => [
const [isLogin, isLoginWithAuth] = useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
authSelectors.enabledAuth(s),
]);
const profile: CellProps[] = [

View File

@@ -6,15 +6,15 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Cell, { CellProps } from '@/components/Cell';
import { enableAuth } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import { ProfileTabs } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
const Category = memo(() => {
const [isLogin, enableAuth, isLoginWithClerk, signOut] = useUserStore((s) => [
const [isLogin, isLoginWithClerk, signOut] = useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.enabledAuth(s),
authSelectors.isLoginWithClerk(s),
s.logout,
]);

View File

@@ -4,6 +4,7 @@ import { Form, type ItemGroup } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { enableAuth } from '@/const/auth';
import { FORM_STYLE } from '@/const/layoutTokens';
import AvatarWithUpload from '@/features/AvatarWithUpload';
import UserAvatar from '@/features/User/UserAvatar';
@@ -14,8 +15,7 @@ type SettingItemGroup = ItemGroup;
const Client = memo<{ mobile?: boolean }>(() => {
const [isLoginWithNextAuth] = useUserStore((s) => [authSelectors.isLoginWithNextAuth(s)]);
const [enableAuth, nickname, username, userProfile] = useUserStore((s) => [
s.enableAuth(),
const [nickname, username, userProfile] = useUserStore((s) => [
userProfileSelectors.nickName(s),
userProfileSelectors.username(s),
userProfileSelectors.userProfile(s),

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useTranslation } from 'react-i18next';
import type { MenuProps } from '@/components/Menu';
import { enableAuth } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import { ProfileTabs } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
@@ -11,10 +12,7 @@ import { authSelectors } from '@/store/user/slices/auth/selectors';
export const useCategory = () => {
const { t } = useTranslation('auth');
const [enableAuth, isLoginWithClerk] = useUserStore((s) => [
authSelectors.enabledAuth(s),
authSelectors.isLoginWithClerk(s),
]);
const [isLoginWithClerk] = useUserStore((s) => [authSelectors.isLoginWithClerk(s)]);
const cateItems: MenuProps['items'] = [
{

View File

@@ -6,13 +6,12 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { enableAuth } from '@/const/auth';
import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
import { SettingsTabs } from '@/store/global/initialState';
import { useSessionStore } from '@/store/session';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
const Header = memo(() => {
@@ -22,7 +21,6 @@ const Header = memo(() => {
const showMobileWorkspace = useShowMobileWorkspace();
const activeSettingsKey = useActiveSettingsKey();
const isSessionActive = useSessionStore((s) => !!s.activeId);
const enableAuth = useUserStore(authSelectors.enabledAuth);
const handleBackClick = () => {
if (isSessionActive && showMobileWorkspace) {

View File

@@ -7,4 +7,3 @@ export const metadata: Metadata = {
};
export { default } from './loading';

View File

@@ -217,8 +217,7 @@ export const getAuthConfig = () => {
CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
// Next Auth
NEXT_PUBLIC_ENABLE_NEXT_AUTH:
!!process.env.NEXT_AUTH_SECRET || process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1',
NEXT_PUBLIC_ENABLE_NEXT_AUTH: process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1',
NEXT_AUTH_SSO_PROVIDERS: process.env.NEXT_AUTH_SSO_PROVIDERS,
NEXT_AUTH_SECRET: process.env.NEXT_AUTH_SECRET,
NEXT_AUTH_DEBUG: !!process.env.NEXT_AUTH_DEBUG,

View File

@@ -2,8 +2,7 @@ import { authEnv } from '@/config/auth';
export const enableClerk = authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH;
export const enableNextAuth = authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH;
export const enableAuth =
authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH || authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH;
export const enableAuth = enableClerk || enableNextAuth || false;
export const LOBE_CHAT_AUTH_HEADER = 'X-lobe-chat-auth';

View File

@@ -5,6 +5,7 @@ import { Flexbox } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
import Menu from '@/components/Menu';
import { enableAuth, enableNextAuth } from '@/const/auth';
import { isDeprecatedEdition } from '@/const/version';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
@@ -19,12 +20,7 @@ import { useMenu } from './useMenu';
const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
const router = useRouter();
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
const [openSignIn, signOut, enableAuth, enabledNextAuth] = useUserStore((s) => [
s.openLogin,
s.logout,
s.enableAuth(),
s.enabledNextAuth,
]);
const [openSignIn, signOut] = useUserStore((s) => [s.openLogin, s.logout]);
const { mainItems, logoutItems } = useMenu();
const handleSignIn = () => {
@@ -36,7 +32,7 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
signOut();
closePopover();
// NextAuth doesn't need to redirect to login page
if (enabledNextAuth) return;
if (enableNextAuth) return;
router.push('/login');
};

View File

@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import type { MenuProps } from '@/components/Menu';
import { enableAuth } from '@/const/auth';
import { LOBE_CHAT_CLOUD } from '@/const/branding';
import {
DISCORD,
@@ -68,8 +69,7 @@ export const useMenu = () => {
const hasNewVersion = useNewVersion();
const { t } = useTranslation(['common', 'setting', 'auth']);
const { showCloudPromotion, hideDocs } = useServerConfigStore(featureFlagsSelectors);
const [enableAuth, isLogin, isLoginWithAuth] = useUserStore((s) => [
authSelectors.enabledAuth(s),
const [isLogin, isLoginWithAuth] = useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
]);

View File

@@ -68,13 +68,12 @@ vi.mock('@/const/version', () => ({
// 定义一个变量来存储 enableAuth 的值
let enableAuth = true;
beforeEach(() => {
useUserStore.setState({ enableAuth: () => true });
});
afterEach(() => {
enableAuth = true;
});
// 模拟 @/const/auth 模块
vi.mock('@/const/auth', () => ({
get enableAuth() {
return enableAuth;
},
}));
describe('PanelContent', () => {
const closePopover = vi.fn();

View File

@@ -5,6 +5,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { createStoreUpdater } from 'zustand-utils';
import { enableNextAuth } from '@/const/auth';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useEnabledDataSync } from '@/hooks/useSyncData';
import { useAgentStore } from '@/store/agent';
@@ -39,8 +40,6 @@ const StoreInitialization = memo(() => {
// Update NextAuth status
const useUserStoreUpdater = createStoreUpdater(useUserStore);
const enableNextAuth = useServerConfigStore(serverConfigSelectors.enabledOAuthSSO);
useUserStoreUpdater('enabledNextAuth', enableNextAuth);
const oAuthSSOProviders = useServerConfigStore(serverConfigSelectors.oAuthSSOProviders);
useUserStoreUpdater('oAuthSSOProviders', oAuthSSOProviders);

View File

@@ -1,7 +1,6 @@
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { UAParser } from 'ua-parser-js';
import urlJoin from 'url-join';
import { authEnv } from '@/config/auth';
import { LOBE_THEME_APPEARANCE } from '@/const/theme';
@@ -84,23 +83,21 @@ const defaultMiddleware = (request: NextRequest) => {
if (['/api', '/trpc', '/webapi'].some((path) => url.pathname.startsWith(path)))
return NextResponse.next();
// 处理 URL 重写
// 构建新路径: /${route}${originalPathname}
// 只对 GET 请求进行 URL 重写,确保其他类型的请求(包括 OPTIONS不受影响
const nextPathname = `/${urlJoin(route, url.pathname)}`;
// refs: https://github.com/lobehub/lobe-chat/pull/5866
// new handle segment rewrite: /${route}${originalPathname}
// / -> /zh-CN__0__dark
// /discover -> /zh-CN__0__dark/discover
const nextPathname = `/${route}` + (url.pathname === '/' ? '' : url.pathname);
console.log(`[rewrite] ${url.pathname} -> ${nextPathname}`);
url.pathname = nextPathname;
return NextResponse.rewrite(url);
return NextResponse.rewrite(url, { status: 200 });
};
const publicRoute = ['/', '/discover'];
// Initialize an Edge compatible NextAuth middleware
const nextAuthMiddleware = NextAuthEdge.auth((req) => {
const response = defaultMiddleware(req);
// skip the '/' route
if (publicRoute.some((url) => req.nextUrl.pathname.startsWith(url))) return response;
// Just check if session exists
const session = req.auth;

View File

@@ -3,6 +3,7 @@ import { produce } from 'immer';
import { merge } from 'lodash-es';
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
import { enableAuth } from '@/const/auth';
import { INBOX_GUIDE_SYSTEMROLE } from '@/const/guide';
import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
@@ -515,7 +516,7 @@ class ChatService {
* if enable login and not signed in, return unauthorized error
*/
const userStore = useUserStore.getState();
if (userStore.enableAuth() && !userStore.isSignedIn) {
if (enableAuth && !userStore.isSignedIn) {
throw AgentRuntimeError.createError(ChatErrorType.InvalidAccessCode);
}

View File

@@ -89,7 +89,7 @@ describe('createAuthSlice', () => {
});
it('should call next-auth signOut when NextAuth is enabled', async () => {
useUserStore.setState({ enabledNextAuth: true });
enableNextAuth = true;
const { result } = renderHook(() => useUserStore());
@@ -100,6 +100,7 @@ describe('createAuthSlice', () => {
const { signOut } = await import('next-auth/react');
expect(signOut).toHaveBeenCalled();
enableNextAuth = false;
});
it('should not call next-auth signOut when NextAuth is disabled', async () => {
@@ -143,7 +144,7 @@ describe('createAuthSlice', () => {
});
it('should call next-auth signIn when NextAuth is enabled', async () => {
useUserStore.setState({ enabledNextAuth: true });
enableNextAuth = true;
const { result } = renderHook(() => useUserStore());
@@ -154,6 +155,7 @@ describe('createAuthSlice', () => {
const { signIn } = await import('next-auth/react');
expect(signIn).toHaveBeenCalled();
enableNextAuth = false;
});
it('should not call next-auth signIn when NextAuth is disabled', async () => {
const { result } = renderHook(() => useUserStore());

View File

@@ -1,6 +1,6 @@
import { StateCreator } from 'zustand/vanilla';
import { enableClerk } from '@/const/auth';
import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
import { UserStore } from '../../store';
@@ -23,7 +23,7 @@ export const createAuthSlice: StateCreator<
UserAuthAction
> = (set, get) => ({
enableAuth: () => {
return enableClerk || get()?.enabledNextAuth || false;
return enableAuth;
},
logout: async () => {
if (enableClerk) {
@@ -32,7 +32,6 @@ export const createAuthSlice: StateCreator<
return;
}
const enableNextAuth = get().enabledNextAuth;
if (enableNextAuth) {
const { signOut } = await import('next-auth/react');
signOut();
@@ -50,7 +49,6 @@ export const createAuthSlice: StateCreator<
return;
}
const enableNextAuth = get().enabledNextAuth;
if (enableNextAuth) {
const { signIn } = await import('next-auth/react');
// Check if only one provider is available

View File

@@ -16,7 +16,6 @@ export interface UserAuthState {
clerkSignIn?: (props?: SignInProps) => void;
clerkSignOut?: SignOut;
clerkUser?: UserResource;
enabledNextAuth?: boolean;
isLoaded?: boolean;
isSignedIn?: boolean;

View File

@@ -1,6 +1,6 @@
import { t } from 'i18next';
import { enableClerk } from '@/const/auth';
import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
import { BRANDING_NAME } from '@/const/branding';
import { UserStore } from '@/store/user';
import { LobeUser } from '@/types/user';
@@ -8,7 +8,7 @@ import { LobeUser } from '@/types/user';
const DEFAULT_USERNAME = BRANDING_NAME;
const nickName = (s: UserStore) => {
if (!s.enableAuth()) return t('userPanel.defaultNickname', { ns: 'common' });
if (!enableAuth) return t('userPanel.defaultNickname', { ns: 'common' });
if (s.isSignedIn) return s.user?.fullName || s.user?.username;
@@ -16,7 +16,7 @@ const nickName = (s: UserStore) => {
};
const username = (s: UserStore) => {
if (!s.enableAuth()) return DEFAULT_USERNAME;
if (!enableAuth) return DEFAULT_USERNAME;
if (s.isSignedIn) return s.user?.username;
@@ -36,17 +36,15 @@ export const userProfileSelectors = {
*/
const isLogin = (s: UserStore) => {
// 如果没有开启鉴权,说明不需要登录,默认是登录态
if (!s.enableAuth()) return true;
if (!enableAuth) return true;
return s.isSignedIn;
};
export const authSelectors = {
enabledAuth: (s: UserStore): boolean => s.enableAuth(),
enabledNextAuth: (s: UserStore): boolean => !!s.enabledNextAuth,
isLoaded: (s: UserStore) => s.isLoaded,
isLogin,
isLoginWithAuth: (s: UserStore) => s.isSignedIn,
isLoginWithClerk: (s: UserStore): boolean => (s.isSignedIn && enableClerk) || false,
isLoginWithNextAuth: (s: UserStore): boolean => (s.isSignedIn && !!s.enabledNextAuth) || false,
isLoginWithNextAuth: (s: UserStore): boolean => (s.isSignedIn && !!enableNextAuth) || false,
};

View File

@@ -99,7 +99,6 @@ export const createCommonSlice: StateCreator<
set(
{
defaultSettings,
enabledNextAuth: serverConfig.enabledOAuthSSO,
isOnboard: data.isOnboard,
isShowPWAGuide: data.canEnablePWAGuide,
isUserCanEnableTrace: data.canEnableTrace,