diff --git a/docs/self-hosting/advanced/authentication.mdx b/docs/self-hosting/advanced/authentication.mdx index 02570c1ffcf5..c8517740365f 100644 --- a/docs/self-hosting/advanced/authentication.mdx +++ b/docs/self-hosting/advanced/authentication.mdx @@ -25,6 +25,15 @@ By setting the environment variables NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK ## Next Auth +Before using NextAuth, please set the following variables in LobeChat's environment variables: + +| Environment Variable | Type | Description | +| --- | --- | --- | +| `NEXT_AUTH_SECRET` | Required | The key used to encrypt Auth.js session tokens. You can use the following command: `openssl rand -base64 32`, or visit `https://generate-secret.vercel.app/32` to generate the key. | +| `ACCESS_CODE` | Required | Add a password to access this service. You can set a sufficiently long random password to "disable" access code authorization. | +| `NEXTAUTH_URL` | Optional | This URL specifies the callback address for Auth.js when performing OAuth verification. Set this only if the default generated redirect address is incorrect. `https://example.com/api/auth` | +| `NEXT_AUTH_SSO_PROVIDERS` | Optional | This environment variable is used to enable multiple identity verification sources simultaneously, separated by commas, for example, `auth0,azure-ad,authentik`. | + Currently supported identity verification services include: diff --git a/docs/self-hosting/advanced/authentication.zh-CN.mdx b/docs/self-hosting/advanced/authentication.zh-CN.mdx index 3bbd5cb91f11..df69d357f6d4 100644 --- a/docs/self-hosting/advanced/authentication.zh-CN.mdx +++ b/docs/self-hosting/advanced/authentication.zh-CN.mdx @@ -22,6 +22,15 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全 ## Next Auth +在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量: + +| 环境变量 | 类型 | 描述 | +| --- | --- | --- | +| `NEXT_AUTH_SECRET` | 必选 | 用于加密 Auth.js 会话令牌的密钥。您可以使用以下命令: `openssl rand -base64 32`,或者访问 `https://generate-secret.vercel.app/32` 生成秘钥。 | +| `ACCESS_CODE` | 必选 | 添加访问此服务的密码,你可以设置一个足够长的随机密码以 “禁用” 访问码授权 | +| `NEXTAUTH_URL` | 可选 | 该 URL 用于指定 Auth.js 在执行 OAuth 验证时的回调地址,当默认生成的重定向地址发生不正确时才需要设置。`https://example.com/api/auth` | +| `NEXT_AUTH_SSO_PROVIDERS` | 可选 | 该环境变量用于同时启用多个身份验证源,以逗号 `,` 分割,例如 `auth0,azure-ad,authentik`。 | + 目前支持的身份验证服务有: diff --git a/src/app/(main)/settings/common/features/Common.tsx b/src/app/(main)/settings/common/features/Common.tsx index cd1db5a1e886..7a68e32d8e8e 100644 --- a/src/app/(main)/settings/common/features/Common.tsx +++ b/src/app/(main)/settings/common/features/Common.tsx @@ -3,7 +3,6 @@ import { Form, type ItemGroup } from '@lobehub/ui'; import { App, Button, Input } from 'antd'; import isEqual from 'fast-deep-equal'; -import { signIn, signOut } from 'next-auth/react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -12,6 +11,8 @@ import { FORM_STYLE } from '@/const/layoutTokens'; import { DEFAULT_SETTINGS } from '@/const/settings'; import { useChatStore } from '@/store/chat'; import { useFileStore } from '@/store/file'; +import { useServerConfigStore } from '@/store/serverConfig'; +import { serverConfigSelectors } from '@/store/serverConfig/selectors'; import { useSessionStore } from '@/store/session'; import { useToolStore } from '@/store/tool'; import { useUserStore } from '@/store/user'; @@ -19,16 +20,13 @@ import { settingsSelectors, userProfileSelectors } from '@/store/user/selectors' type SettingItemGroup = ItemGroup; -export interface SettingsCommonProps { - showAccessCodeConfig: boolean; - showOAuthLogin?: boolean; -} - -const Common = memo(({ showAccessCodeConfig, showOAuthLogin }) => { +const Common = memo(() => { const { t } = useTranslation('setting'); const [form] = Form.useForm(); const isSignedIn = useUserStore((s) => s.isSignedIn); + const showAccessCodeConfig = useServerConfigStore(serverConfigSelectors.enabledAccessCode); + const showOAuthLogin = useServerConfigStore(serverConfigSelectors.enabledOAuthSSO); const user = useUserStore(userProfileSelectors.userProfile, isEqual); const [clearSessions, clearSessionGroups] = useSessionStore((s) => [ @@ -42,7 +40,12 @@ const Common = memo(({ showAccessCodeConfig, showOAuthLogin const [removeAllFiles] = useFileStore((s) => [s.removeAllFiles]); const removeAllPlugins = useToolStore((s) => s.removeAllPlugins); const settings = useUserStore(settingsSelectors.currentSettings, isEqual); - const [setSettings, resetSettings] = useUserStore((s) => [s.setSettings, s.resetSettings]); + const [setSettings, resetSettings, signIn, signOut] = useUserStore((s) => [ + s.setSettings, + s.resetSettings, + s.openLogin, + s.logout, + ]); const { message, modal } = App.useApp(); diff --git a/src/app/(main)/settings/common/index.tsx b/src/app/(main)/settings/common/index.tsx index 5017da805f48..5cd7510c01c7 100644 --- a/src/app/(main)/settings/common/index.tsx +++ b/src/app/(main)/settings/common/index.tsx @@ -1,19 +1,11 @@ -import { authEnv } from '@/config/auth'; -import { getServerConfig } from '@/config/server'; - import Common from './features/Common'; import Theme from './features/Theme'; const Page = () => { - const { SHOW_ACCESS_CODE_CONFIG } = getServerConfig(); - return ( <> - + ); }; diff --git a/src/features/Conversation/Error/OAuthForm.tsx b/src/features/Conversation/Error/OAuthForm.tsx index 8f7ce009b55d..14dbbc977de1 100644 --- a/src/features/Conversation/Error/OAuthForm.tsx +++ b/src/features/Conversation/Error/OAuthForm.tsx @@ -1,20 +1,22 @@ import { Icon } from '@lobehub/ui'; import { App, Button } from 'antd'; import { ScanFace } from 'lucide-react'; -import { signIn, signOut } from 'next-auth/react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Center, Flexbox } from 'react-layout-kit'; -import { useOAuthSession } from '@/hooks/useOAuthSession'; import { useChatStore } from '@/store/chat'; +import { useUserStore } from '@/store/user'; +import { authSelectors, userProfileSelectors } from '@/store/user/selectors'; import { FormAction } from './style'; const OAuthForm = memo<{ id: string }>(({ id }) => { const { t } = useTranslation('error'); - const { user, isOAuthLoggedIn } = useOAuthSession(); + const [signIn, signOut] = useUserStore((s) => [s.openLogin, s.logout]); + const user = useUserStore(userProfileSelectors.userProfile); + const isOAuthLoggedIn = useUserStore(authSelectors.isLoginWithAuth); const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]); @@ -38,7 +40,7 @@ const OAuthForm = memo<{ id: string }>(({ id }) => { avatar={isOAuthLoggedIn ? '✅' : '🕵️‍♂️'} description={ isOAuthLoggedIn - ? `${t('unlock.oauth.welcome')} ${user?.name}` + ? `${t('unlock.oauth.welcome')} ${user?.fullName || ''}` : t('unlock.oauth.description') } title={isOAuthLoggedIn ? t('unlock.oauth.success') : t('unlock.oauth.title')} diff --git a/src/features/User/__tests__/UserAvatar.test.tsx b/src/features/User/__tests__/UserAvatar.test.tsx index 18e5ada92bb2..5052c35bc84d 100644 --- a/src/features/User/__tests__/UserAvatar.test.tsx +++ b/src/features/User/__tests__/UserAvatar.test.tsx @@ -30,6 +30,7 @@ describe('UserAvatar', () => { act(() => { useUserStore.setState({ + enableAuth: () => true, isSignedIn: true, user: { avatar: mockAvatar, id: 'abc', username: mockUsername }, }); @@ -45,7 +46,11 @@ describe('UserAvatar', () => { const mockUsername = 'testuser'; act(() => { - useUserStore.setState({ isSignedIn: true, user: { id: 'bbb', username: mockUsername } }); + useUserStore.setState({ + enableAuth: () => true, + isSignedIn: true, + user: { id: 'bbb', username: mockUsername }, + }); }); render(); @@ -54,7 +59,7 @@ describe('UserAvatar', () => { it('should show LobeChat and default avatar when the user is not logged in and enable auth', () => { act(() => { - useUserStore.setState({ isSignedIn: false, user: undefined }); + useUserStore.setState({ enableAuth: () => true, isSignedIn: false, user: undefined }); }); render(); @@ -67,7 +72,7 @@ describe('UserAvatar', () => { it('should show LobeChat and default avatar when the user is not logged in and disabled auth', () => { enableAuth = false; act(() => { - useUserStore.setState({ isSignedIn: false, user: undefined }); + useUserStore.setState({ enableAuth: () => false, isSignedIn: false, user: undefined }); }); render(); diff --git a/src/features/User/__tests__/useMenu.test.tsx b/src/features/User/__tests__/useMenu.test.tsx index c186ae831c44..9ec0377df96e 100644 --- a/src/features/User/__tests__/useMenu.test.tsx +++ b/src/features/User/__tests__/useMenu.test.tsx @@ -64,7 +64,7 @@ afterEach(() => { describe('useMenu', () => { it('should provide correct menu items when user is logged in with auth', () => { act(() => { - useUserStore.setState({ isSignedIn: true }); + useUserStore.setState({ isSignedIn: true, enableAuth: () => true }); }); enableAuth = true; enableClerk = false; @@ -104,7 +104,7 @@ describe('useMenu', () => { it('should provide correct menu items when user is logged in without auth', () => { act(() => { - useUserStore.setState({ isSignedIn: false }); + useUserStore.setState({ isSignedIn: false, enableAuth: () => false }); }); enableAuth = false; @@ -123,7 +123,7 @@ describe('useMenu', () => { it('should provide correct menu items when user is not logged in', () => { act(() => { - useUserStore.setState({ isSignedIn: false }); + useUserStore.setState({ isSignedIn: false, enableAuth: () => true }); }); enableAuth = true; diff --git a/src/hooks/useOAuthSession.ts b/src/hooks/useOAuthSession.ts deleted file mode 100644 index 8b86a01d14a8..000000000000 --- a/src/hooks/useOAuthSession.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { User } from '@auth/core/types'; -import { SessionContextValue, useSession } from 'next-auth/react'; -import { useMemo } from 'react'; - -interface OAuthSession { - isOAuthLoggedIn: boolean; - user?: User | null; -} - -export const useOAuthSession = () => { - let authSession: SessionContextValue | null; - try { - // refs: https://github.com/lobehub/lobe-chat/pull/1286 - // eslint-disable-next-line react-hooks/rules-of-hooks - authSession = useSession(); - } catch { - authSession = null; - } - - const { data: session, status } = authSession || {}; - const isOAuthLoggedIn = (status === 'authenticated' && session && !!session.user) || false; - - return useMemo(() => ({ isOAuthLoggedIn, user: session?.user }), [session, status]); -}; diff --git a/src/libs/next-auth/index.ts b/src/libs/next-auth/index.ts index 1fdfb2a8f11a..fbefb4054b71 100644 --- a/src/libs/next-auth/index.ts +++ b/src/libs/next-auth/index.ts @@ -44,10 +44,3 @@ export const { handlers: { GET, POST }, auth, } = nextAuth; - -declare module '@auth/core/jwt' { - // Returned by the `jwt` callback and `auth`, when using JWT sessions - interface JWT { - userId?: string; - } -} diff --git a/src/server/globalConfig/index.ts b/src/server/globalConfig/index.ts index c062be939a31..1d3dc74b6dc5 100644 --- a/src/server/globalConfig/index.ts +++ b/src/server/globalConfig/index.ts @@ -13,6 +13,7 @@ import { parseAgentConfig } from './parseDefaultAgent'; export const getServerGlobalConfig = () => { const { + ACCESS_CODES, ENABLE_LANGFUSE, DEFAULT_AGENT_CONFIG, @@ -49,6 +50,7 @@ export const getServerGlobalConfig = () => { config: parseAgentConfig(DEFAULT_AGENT_CONFIG), }, + enabledAccessCode: ACCESS_CODES?.length > 0, enabledOAuthSSO: enableNextAuth, languageModel: { anthropic: { diff --git a/src/store/serverConfig/selectors.ts b/src/store/serverConfig/selectors.ts index 8d2e3130ae3b..b4be747c7f4d 100644 --- a/src/store/serverConfig/selectors.ts +++ b/src/store/serverConfig/selectors.ts @@ -6,6 +6,7 @@ export const featureFlagsSelectors = (s: ServerConfigStore) => mapFeatureFlagsEnvToState(s.featureFlags); export const serverConfigSelectors = { + enabledAccessCode: (s: ServerConfigStore) => !!s.serverConfig?.enabledAccessCode, enabledOAuthSSO: (s: ServerConfigStore) => s.serverConfig.enabledOAuthSSO, enabledTelemetryChat: (s: ServerConfigStore) => s.serverConfig.telemetry.langfuse || false, isMobile: (s: ServerConfigStore) => s.isMobile || false, diff --git a/src/store/user/slices/auth/action.test.ts b/src/store/user/slices/auth/action.test.ts index eaba91fa5b50..dc1866a9d63d 100644 --- a/src/store/user/slices/auth/action.test.ts +++ b/src/store/user/slices/auth/action.test.ts @@ -43,6 +43,16 @@ afterEach(() => { enableClerk = false; }); +/** + * Mock nextauth 库相关方法 + */ +vi.mock('next-auth/react', async () => { + return { + signIn: vi.fn(), + signOut: vi.fn(), + }; +}); + describe('createAuthSlice', () => { describe('refreshUserConfig', () => { it('should refresh user config', async () => { @@ -162,6 +172,32 @@ describe('createAuthSlice', () => { expect(clerkSignOutMock).not.toHaveBeenCalled(); }); + + it('should call next-auth signOut when NextAuth is enabled', async () => { + useUserStore.setState({ enabledNextAuth: () => true }); + + const { result } = renderHook(() => useUserStore()); + + await act(async () => { + await result.current.logout(); + }); + + const { signOut } = await import('next-auth/react'); + + expect(signOut).toHaveBeenCalled(); + }); + + it('should not call next-auth signOut when NextAuth is disabled', async () => { + const { result } = renderHook(() => useUserStore()); + + await act(async () => { + await result.current.logout(); + }); + + const { signOut } = await import('next-auth/react'); + + expect(signOut).not.toHaveBeenCalled(); + }); }); describe('openLogin', () => { @@ -190,6 +226,31 @@ describe('createAuthSlice', () => { expect(clerkSignInMock).not.toHaveBeenCalled(); }); + + it('should call next-auth signIn when NextAuth is enabled', async () => { + useUserStore.setState({ enabledNextAuth: () => true }); + + const { result } = renderHook(() => useUserStore()); + + await act(async () => { + await result.current.openLogin(); + }); + + const { signIn } = await import('next-auth/react'); + + expect(signIn).toHaveBeenCalled(); + }); + it('should not call next-auth signIn when NextAuth is disabled', async () => { + const { result } = renderHook(() => useUserStore()); + + await act(async () => { + await result.current.openLogin(); + }); + + const { signIn } = await import('next-auth/react'); + + expect(signIn).not.toHaveBeenCalled(); + }); }); describe('openUserProfile', () => { diff --git a/src/store/user/slices/auth/action.ts b/src/store/user/slices/auth/action.ts index 8caf2762749c..24b2080c9967 100644 --- a/src/store/user/slices/auth/action.ts +++ b/src/store/user/slices/auth/action.ts @@ -1,7 +1,7 @@ import useSWR, { SWRResponse, mutate } from 'swr'; import { StateCreator } from 'zustand/vanilla'; -import { enableClerk, enableNextAuth } from '@/const/auth'; +import { enableClerk } from '@/const/auth'; import { UserConfig, userService } from '@/services/user'; import { switchLang } from '@/utils/client/switchLang'; import { setNamespace } from '@/utils/storeDebug'; @@ -13,8 +13,9 @@ const n = setNamespace('auth'); const USER_CONFIG_FETCH_KEY = 'fetchUserConfig'; export interface UserAuthAction { + enableAuth: () => boolean; + enabledNextAuth: () => boolean; getUserConfig: () => void; - login: () => Promise; /** * universal logout method */ @@ -24,8 +25,8 @@ export interface UserAuthAction { */ openLogin: () => Promise; openUserProfile: () => Promise; - refreshUserConfig: () => Promise; + refreshUserConfig: () => Promise; useFetchUserConfig: (initServer: boolean) => SWRResponse; } @@ -35,13 +36,15 @@ export const createAuthSlice: StateCreator< [], UserAuthAction > = (set, get) => ({ + enableAuth: () => { + return enableClerk || get()?.enabledNextAuth(); + }, + enabledNextAuth: () => { + return !!get()?.serverConfig.enabledOAuthSSO; + }, getUserConfig: () => { console.log(n('userconfig')); }, - login: async () => { - // TODO: 针对开启 next-auth 的场景,需要在这里调用登录方法 - console.log(n('login')); - }, logout: async () => { if (enableClerk) { get().clerkSignOut?.({ redirectUrl: location.toString() }); @@ -49,9 +52,10 @@ export const createAuthSlice: StateCreator< return; } + const enableNextAuth = get().enabledNextAuth(); if (enableNextAuth) { - // TODO: 针对开启 next-auth 的场景,需要在这里调用登录方法 - console.log(n('logout')); + const { signOut } = await import('next-auth/react'); + signOut(); } }, openLogin: async () => { @@ -63,20 +67,19 @@ export const createAuthSlice: StateCreator< return; } + const enableNextAuth = get().enabledNextAuth(); if (enableNextAuth) { - // TODO: 针对开启 next-auth 的场景,需要在这里调用登录方法 + const { signIn } = await import('next-auth/react'); + signIn(); } }, + openUserProfile: async () => { if (enableClerk) { get().clerkOpenUserProfile?.(); return; } - - if (enableNextAuth) { - // TODO: 针对开启 next-auth 的场景,需要在这里调用打开 profile 页 - } }, refreshUserConfig: async () => { await mutate([USER_CONFIG_FETCH_KEY, true]); @@ -84,7 +87,6 @@ export const createAuthSlice: StateCreator< // when get the user config ,refresh the model provider list to the latest get().refreshModelProviderList(); }, - useFetchUserConfig: (initServer) => useSWR( [USER_CONFIG_FETCH_KEY, initServer], diff --git a/src/store/user/slices/auth/selectors.test.ts b/src/store/user/slices/auth/selectors.test.ts index b3b95de3f2e8..0faed5cc2c0c 100644 --- a/src/store/user/slices/auth/selectors.test.ts +++ b/src/store/user/slices/auth/selectors.test.ts @@ -31,6 +31,7 @@ describe('userProfileSelectors', () => { const store: UserStore = { isSignedIn: false, user: null, + enableAuth: () => false, } as unknown as UserStore; expect(userProfileSelectors.nickName(store)).toBe('userPanel.defaultNickname'); @@ -43,6 +44,7 @@ describe('userProfileSelectors', () => { const store: UserStore = { isSignedIn: true, user: { fullName: 'John Doe' }, + enableAuth: () => true, } as UserStore; expect(userProfileSelectors.nickName(store)).toBe('John Doe'); @@ -52,6 +54,7 @@ describe('userProfileSelectors', () => { const store: UserStore = { isSignedIn: true, user: { username: 'johndoe' }, + enableAuth: () => true, } as UserStore; expect(userProfileSelectors.nickName(store)).toBe('johndoe'); @@ -60,7 +63,11 @@ describe('userProfileSelectors', () => { it('should return anonymous nickname when not signed in', () => { enableAuth = true; - const store: UserStore = { isSignedIn: false, user: null } as unknown as UserStore; + const store: UserStore = { + enableAuth: () => true, + isSignedIn: false, + user: null, + } as unknown as UserStore; expect(userProfileSelectors.nickName(store)).toBe('userPanel.anonymousNickName'); expect(t).toHaveBeenCalledWith('userPanel.anonymousNickName', { ns: 'common' }); @@ -74,6 +81,7 @@ describe('userProfileSelectors', () => { const store: UserStore = { isSignedIn: false, user: null, + enableAuth: () => false, } as unknown as UserStore; expect(userProfileSelectors.username(store)).toBe('LobeChat'); @@ -83,13 +91,18 @@ describe('userProfileSelectors', () => { const store: UserStore = { isSignedIn: true, user: { username: 'johndoe' }, + enableAuth: () => true, } as UserStore; expect(userProfileSelectors.username(store)).toBe('johndoe'); }); it('should return "anonymous" when not signed in', () => { - const store: UserStore = { isSignedIn: false, user: null } as unknown as UserStore; + const store: UserStore = { + enableAuth: () => true, + isSignedIn: false, + user: null, + } as unknown as UserStore; expect(userProfileSelectors.username(store)).toBe('anonymous'); }); @@ -103,6 +116,7 @@ describe('authSelectors', () => { const store: UserStore = { isSignedIn: false, + enableAuth: () => false, } as UserStore; expect(authSelectors.isLogin(store)).toBe(true); @@ -111,6 +125,7 @@ describe('authSelectors', () => { it('should return true when signed in', () => { const store: UserStore = { isSignedIn: true, + enableAuth: () => true, } as UserStore; expect(authSelectors.isLogin(store)).toBe(true); @@ -119,6 +134,7 @@ describe('authSelectors', () => { it('should return false when not signed in and auth is enabled', () => { const store: UserStore = { isSignedIn: false, + enableAuth: () => true, } as UserStore; expect(authSelectors.isLogin(store)).toBe(false); diff --git a/src/store/user/slices/auth/selectors.ts b/src/store/user/slices/auth/selectors.ts index 45b61da571c1..9cd7fac95cee 100644 --- a/src/store/user/slices/auth/selectors.ts +++ b/src/store/user/slices/auth/selectors.ts @@ -1,13 +1,13 @@ import { t } from 'i18next'; -import { enableAuth, enableClerk } from '@/const/auth'; +import { enableClerk } from '@/const/auth'; import { UserStore } from '@/store/user'; import { LobeUser } from '@/types/user'; const DEFAULT_USERNAME = 'LobeChat'; const nickName = (s: UserStore) => { - if (!enableAuth) return t('userPanel.defaultNickname', { ns: 'common' }); + if (!s.enableAuth()) return t('userPanel.defaultNickname', { ns: 'common' }); if (s.isSignedIn) return s.user?.fullName || s.user?.username; @@ -15,7 +15,7 @@ const nickName = (s: UserStore) => { }; const username = (s: UserStore) => { - if (!enableAuth) return DEFAULT_USERNAME; + if (!s.enableAuth()) return DEFAULT_USERNAME; if (s.isSignedIn) return s.user?.username; @@ -35,7 +35,7 @@ export const userProfileSelectors = { */ const isLogin = (s: UserStore) => { // 如果没有开启鉴权,说明不需要登录,默认是登录态 - if (!enableAuth) return true; + if (!s.enableAuth()) return true; return s.isSignedIn; }; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 000000000000..26dabb419014 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,23 @@ +import { type DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + /** + * Returned by `useSession`, `auth`, contains information about the active session. + */ + interface Session { + user: { + firstName?: string; + } & DefaultSession['user']; + } + /** + * More types can be extends here + * ref: https://authjs.dev/getting-started/typescript + */ +} + +declare module '@auth/core/jwt' { + /** Returned by the `jwt` callback and `auth`, when using JWT sessions */ + interface JWT { + userId: string; + } +} diff --git a/src/types/serverConfig.ts b/src/types/serverConfig.ts index 031e88e772b2..0e90b4af9373 100644 --- a/src/types/serverConfig.ts +++ b/src/types/serverConfig.ts @@ -15,6 +15,7 @@ export interface ServerModelProviderConfig { export interface GlobalServerConfig { defaultAgent?: DeepPartial; + enabledAccessCode?: boolean; enabledOAuthSSO?: boolean; languageModel?: Partial>; telemetry: {