feat: remove NextAuth (#11732)

This commit is contained in:
YuTengjing 2026-01-23 23:57:08 +08:00 committed by GitHub
parent 0fcf8b0def
commit 1eff8646f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 2989 additions and 3563 deletions

View file

@ -5,4 +5,3 @@ KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=
DATABASE_URL=postgresql://postgres@localhost:5432/postgres
SEARCH_PROVIDERS=search1api
NEXT_PUBLIC_IS_DESKTOP_APP=1
NEXT_PUBLIC_ENABLE_NEXT_AUTH=0

View file

@ -24,9 +24,9 @@
# Example: Allow specific internal servers while keeping SSRF protection
# SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
########################################
############ Redis Settings ############
########################################
# #######################################
# ########### Redis Settings ############
# #######################################
# Connection string for self-hosted Redis (Docker/K8s/managed). Use container hostname when running via docker-compose.
# REDIS_URL=redis://localhost:6379
@ -44,9 +44,9 @@
# Namespace prefix for cache/queue keys.
# REDIS_PREFIX=lobechat
########################################
########## AI Provider Service #########
########################################
# #######################################
# ######### AI Provider Service #########
# #######################################
# ## OpenAI ###
@ -277,25 +277,12 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# ########### Auth Service ##############
# #######################################
# NextAuth related configurations
# NEXT_PUBLIC_ENABLE_NEXT_AUTH=1
# NEXT_AUTH_SECRET=
# Auth0 configurations
# AUTH_AUTH0_ID=
# AUTH_AUTH0_SECRET=
# AUTH_AUTH0_ISSUER=https://your-domain.auth0.com
# Better-Auth related configurations
# NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
# Auth Secret (use `openssl rand -base64 32` to generate)
# Shared between Better-Auth and Next-Auth
# AUTH_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Require email verification before allowing users to sign in (default: false)
# Set to '1' to force users to verify their email before signing in
# NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0
# AUTH_EMAIL_VERIFICATION=0
# SSO Providers Configuration (for Better-Auth)
# Comma-separated list of enabled OAuth providers
@ -303,6 +290,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Example: AUTH_SSO_PROVIDERS=google,github,auth0,microsoft-entra-id
# AUTH_SSO_PROVIDERS=
# Email whitelist for registration (comma-separated)
# Supports full email (user@example.com) or domain (example.com)
# Leave empty to allow all emails
# AUTH_ALLOWED_EMAILS=example.com,admin@other.com
# Google OAuth Configuration (for Better-Auth)
# Get credentials from: https://console.cloud.google.com/apis/credentials
# Authorized redirect URIs:
@ -366,6 +358,10 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# SMTP authentication password (use app-specific password for Gmail)
# SMTP_PASS=your-password-or-app-specific-password
# Sender email address (optional, defaults to SMTP_USER)
# Required for AWS SES where SMTP_USER is not a valid email address
# SMTP_FROM=noreply@example.com
# #######################################
# ######### Server Database #############
# #######################################

View file

@ -37,10 +37,7 @@ REDIS_PREFIX=lobechat
REDIS_TLS=0
# Authentication Configuration
# Enable Better Auth authentication
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
# Better Auth secret for JWT signing (generate with: openssl rand -base64 32)
# Auth secret for JWT signing (generate with: openssl rand -base64 32)
AUTH_SECRET=${UNSAFE_SECRET}
# SSO providers configuration - using Casdoor for development

View file

@ -14,9 +14,8 @@ env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DATABASE_DRIVER: node
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
BETTER_AUTH_SECRET: e2e-test-secret-key-for-better-auth-32chars!
NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1'
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: '0'
AUTH_SECRET: e2e-test-secret-key-for-better-auth-32chars!
AUTH_EMAIL_VERIFICATION: "0"
# Mock S3 env vars to prevent initialization errors
S3_ACCESS_KEY_ID: e2e-mock-access-key
S3_SECRET_ACCESS_KEY: e2e-mock-secret-key
@ -34,8 +33,8 @@ jobs:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
concurrent_skipping: "same_content_newer"
skip_after_successful_duplicate: "true"
do_not_skip: '["workflow_dispatch", "schedule"]'
e2e:
@ -75,7 +74,7 @@ jobs:
- name: Build application
run: bun run build
env:
SKIP_LINT: '1'
SKIP_LINT: "1"
- name: Run E2E tests
run: bun run e2e
@ -84,8 +83,8 @@ jobs:
if: failure()
uses: actions/upload-artifact@v6
with:
name: e2e-artifacts
path: |
e2e/reports
e2e/screenshots
if-no-files-found: ignore
name: e2e-artifacts
path: |
e2e/reports
e2e/screenshots
if-no-files-found: ignore

View file

@ -32,7 +32,6 @@ FROM base AS builder
ARG USE_CN_MIRROR
ARG NEXT_PUBLIC_BASE_PATH
ARG NEXT_PUBLIC_ENABLE_NEXT_AUTH
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_ANALYTICS_POSTHOG
ARG NEXT_PUBLIC_POSTHOG_HOST
@ -45,8 +44,7 @@ ARG FEATURE_FLAGS
ENV NEXT_PUBLIC_BASE_PATH="${NEXT_PUBLIC_BASE_PATH}" \
FEATURE_FLAGS="${FEATURE_FLAGS}"
ENV NEXT_PUBLIC_ENABLE_NEXT_AUTH="${NEXT_PUBLIC_ENABLE_NEXT_AUTH:-0}" \
APP_URL="http://app.com" \
ENV APP_URL="http://app.com" \
DATABASE_DRIVER="node" \
DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" \
KEY_VAULTS_SECRET="use-for-build"
@ -183,7 +181,33 @@ ENV KEY_VAULTS_SECRET="" \
# Better Auth
ENV AUTH_SECRET="" \
AUTH_SSO_PROVIDERS=""
AUTH_SSO_PROVIDERS="" \
AUTH_ALLOWED_EMAILS="" \
# Google
AUTH_GOOGLE_ID="" \
AUTH_GOOGLE_SECRET="" \
# GitHub
AUTH_GITHUB_ID="" \
AUTH_GITHUB_SECRET="" \
# Microsoft
AUTH_MICROSOFT_ID="" \
AUTH_MICROSOFT_SECRET=""
# Redis
ENV REDIS_URL="" \
REDIS_PREFIX="" \
REDIS_TLS=""
# Email
ENV EMAIL_SERVICE_PROVIDER="" \
SMTP_HOST="" \
SMTP_PORT="" \
SMTP_SECURE="" \
SMTP_USER="" \
SMTP_PASS="" \
SMTP_FROM="" \
RESEND_API_KEY="" \
RESEND_FROM=""
# S3
ENV NEXT_PUBLIC_S3_DOMAIN="" \

View file

@ -128,9 +128,9 @@ services:
condition: service_healthy
environment:
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
- 'S3_BUCKET=${MINIO_LOBE_BUCKET}'
- 'S3_ENABLE_PATH_STYLE=1'

View file

@ -173,9 +173,9 @@ services:
condition: service_started
environment:
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
- 'S3_BUCKET=${MINIO_LOBE_BUCKET}'
- 'S3_ENABLE_PATH_STYLE=1'

View file

@ -96,9 +96,9 @@ services:
environment:
- 'APP_URL=http://localhost:3210'
- 'NEXT_AUTH_SSO_PROVIDERS=logto'
- 'AUTH_SSO_PROVIDERS=logto'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'NEXTAUTH_URL=http://localhost:${LOBE_PORT}/api/auth'
- 'AUTH_LOGTO_ISSUER=http://localhost:${LOGTO_PORT}/oidc'
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'

View file

@ -10,8 +10,8 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobechat
# NEXT_AUTH related environment variables
NEXTAUTH_URL=http://localhost:3210/api/auth
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
NEXT_AUTH_SSO_PROVIDERS=zitadel
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL provider configuration
# Please refer tohttps://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
AUTH_ZITADEL_ID=285945938244075523

View file

@ -9,8 +9,8 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobechat
# NEXT_AUTH 相关
NEXTAUTH_URL=http://localhost:3210/api/auth
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
NEXT_AUTH_SSO_PROVIDERS=zitadel
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL 鉴权服务提供商部分
# 请参考https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
AUTH_ZITADEL_ID=285945938244075523

View file

@ -171,9 +171,9 @@ services:
condition: service_started
environment:
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
- 'S3_BUCKET=${MINIO_LOBE_BUCKET}'
- 'S3_ENABLE_PATH_STYLE=1'

View file

@ -15,9 +15,9 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# For supported providers, see: https://lobehub.com/docs/self-hosting/advanced/auth#next-auth
# If you have ACCESS_CODE, please remove it. We use NEXT_AUTH as the sole authentication source
# Required: NextAuth secret key. Generate with: openssl rand -base64 32
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
# Required: Specify the authentication provider (e.g., Logto)
NEXT_AUTH_SSO_PROVIDERS=logto
AUTH_SSO_PROVIDERS=logto
# Required: NextAuth URL for callbacks
NEXTAUTH_URL=https://lobe.example.com/api/auth

View file

@ -14,9 +14,9 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# 目前支持的鉴权服务提供商请参考https://lobehub.com/zh/docs/self-hosting/advanced/auth#next-auth
# 如果你有 ACCESS_CODE请务必清空我们以 NEXT_AUTH 作为唯一鉴权来源
# 必填,用于 NextAuth 的密钥,可以使用 openssl rand -base64 32 生成
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
# 必填,指定鉴权服务提供商,这里以 Logto 为例
NEXT_AUTH_SSO_PROVIDERS=logto
AUTH_SSO_PROVIDERS=logto
# 必填NextAuth 的 URL用于 NextAuth 的回调
NEXTAUTH_URL=https://lobe.example.com/api/auth

View file

@ -14,9 +14,9 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# Required: NextAuth URL for callbacks
NEXTAUTH_URL=https://lobe.example.com/api/auth
# Required: NextAuth secret key. Generate with: openssl rand -base64 32
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
# Required: Specify the authentication provider
NEXT_AUTH_SSO_PROVIDERS=zitadel
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL provider configuration
# Please refer tohttps://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel

View file

@ -13,9 +13,9 @@ DATABASE_URL=postgresql://postgres:uWNZugjBqixf8dxC@postgresql:5432/lobe
# 必填NextAuth 的 URL用于 NextAuth 的回调
NEXTAUTH_URL=https://lobe.example.com/api/auth
# 必填,用于 NextAuth 的密钥,可以使用 openssl rand -base64 32 生成
NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg
# 必填,指定鉴权服务提供商
NEXT_AUTH_SSO_PROVIDERS=zitadel
AUTH_SSO_PROVIDERS=zitadel
# ZiTADEL 鉴权服务提供商部分
# 请参考https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel

View file

@ -4,184 +4,192 @@ title: New Authentication Provider Guide
# New Authentication Provider Guide
LobeChat uses [Auth.js v5](https://authjs.dev/) as the external authentication service. Auth.js is an open-source authentication library that provides a simple way to implement authentication and authorization features. This document will introduce how to use Auth.js to implement a new authentication provider.
LobeChat uses [Better Auth](https://www.better-auth.com) as its authentication service. This document explains how to add new SSO authentication providers.
## Add New Authentication Provider
## Architecture Overview
To add a new authentication provider in LobeChat (for example, adding Okta), you need to follow the steps below:
Better Auth SSO providers fall into two categories:
### Pre-requisites: Check the Official Provider List
| Type | Description | Examples |
| --------- | ------------------------------------------- | -------------------------------- |
| `builtin` | Providers natively supported by Better Auth | Google, GitHub, Microsoft, Apple |
| `generic` | Implemented via Generic OIDC/OAuth plugin | Okta, Auth0, Keycloak, etc. |
First, you need to check the [Auth.js Provider List](https://authjs.dev/reference/core/providers) to see if your provider is already supported. If yes, you can directly use the SDK provided by Auth.js to implement the authentication feature.
## Adding a New SSO Provider
Next, I will use [Okta](https://authjs.dev/reference/core/providers/okta) as an example to introduce how to add a new authentication provider.
Using **Okta** as an example, here's how to add a `generic` type provider.
### Step 1: Add Authenticator Core Code
### Step 1: Create Provider Definition File
Open the `src/app/api/auth/next-auth.ts` file and import `next-auth/providers/okta`.
Create `okta.ts` in `src/libs/better-auth/sso/providers/`:
```ts
import { NextAuth } from 'next-auth';
import Auth0 from 'next-auth/providers/auth0';
import Okta from 'next-auth/providers/okta';
import { authEnv } from '@/envs/auth';
// Import Okta provider
```
import { buildOidcConfig } from '../helpers';
import type { GenericProviderDefinition } from '../types';
Add the predefined server configuration.
```ts
// Import server configuration
const { OKTA_CLIENT_ID, OKTA_CLIENT_SECRET, OKTA_ISSUER } = getServerConfig();
const nextAuth = NextAuth({
providers: [
// ... Other providers
Okta({
clientId: OKTA_CLIENT_ID,
clientSecret: OKTA_CLIENT_SECRET,
issuer: OKTA_ISSUER,
const provider: GenericProviderDefinition<{
AUTH_OKTA_ID: string;
AUTH_OKTA_ISSUER: string;
AUTH_OKTA_SECRET: string;
}> = {
// Build OIDC configuration
build: (env) =>
buildOidcConfig({
clientId: env.AUTH_OKTA_ID,
clientSecret: env.AUTH_OKTA_SECRET,
issuer: env.AUTH_OKTA_ISSUER,
overrides: {
// Optional: customize user profile mapping
mapProfileToUser: (profile) => ({
email: profile.email,
name: profile.name ?? profile.preferred_username ?? profile.email ?? profile.sub,
}),
},
providerId: 'okta',
}),
],
});
```
### Step 2: Update Server Configuration Code
// Environment variable validation
checkEnvs: () => {
return !!(authEnv.AUTH_OKTA_ID && authEnv.AUTH_OKTA_SECRET && authEnv.AUTH_OKTA_ISSUER)
? {
AUTH_OKTA_ID: authEnv.AUTH_OKTA_ID,
AUTH_OKTA_ISSUER: authEnv.AUTH_OKTA_ISSUER,
AUTH_OKTA_SECRET: authEnv.AUTH_OKTA_SECRET,
}
: false;
},
Open the `src/config/server/app.ts` file and add Okta-related environment variables in the `getAppConfig` function.
```ts
export const getAppConfig = () => {
// ... Other code
return {
// ... Other environment variables
OKTA_CLIENT_ID: process.env.OKTA_CLIENT_ID || '',
OKTA_CLIENT_SECRET: process.env.OKTA_CLIENT_SECRET || '',
OKTA_ISSUER: process.env.OKTA_ISSUER || '',
};
// Provider ID (used in AUTH_SSO_PROVIDERS)
id: 'okta',
type: 'generic',
};
export default provider;
```
### Step 3: Change Frontend Pages
### Step 2: Register the Provider
Modify the `signIn` function parameter in `src/Features/Conversation/Error/OAuthForm.tsx` and \`src/app/settings/common/Common.tsx
The default is `auth0`, which you can change to `okta` to switch to the Okta provider, or remove this parameter to support all added authentication services
This value is the id of the Auth.js provider, and you can read the source code of the corresponding `next-auth/providers` module to read the default ID.
### Step 4: Configure the Environment Variables
Add `OKTA_CLIENT_ID`、`OKTA_CLIENT_SECRET`、`OKTA_ISSUER` environment variables when you deploy.
### Step 5: Modify server-side user information processing logic
#### Get user information in the frontend
Use the `useOAuthSession()` method in the frontend page to get the user information `user` returned by the backend:
Import and register in `src/libs/better-auth/sso/index.ts`:
```ts
import { useOAuthSession } from '@/hooks/useOAuthSession';
// Import provider
import Okta from './providers/okta';
const { user, isOAuthLoggedIn } = useOAuthSession();
// Add to providerDefinitions array
const providerDefinitions = [
// ... other providers
Okta,
] as const;
```
The default type of `user` is `User`, and the type definition is:
### Step 3: Add Environment Variable Types
Add type declarations in `src/envs/auth.ts`:
```ts
interface User {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
// Add to ProcessEnv interface
AUTH_OKTA_ID?: string;
AUTH_OKTA_SECRET?: string;
AUTH_OKTA_ISSUER?: string;
// Add to getAuthConfig server schema
AUTH_OKTA_ID: z.string().optional(),
AUTH_OKTA_SECRET: z.string().optional(),
AUTH_OKTA_ISSUER: z.string().optional(),
// Add to runtimeEnv
AUTH_OKTA_ID: process.env.AUTH_OKTA_ID,
AUTH_OKTA_SECRET: process.env.AUTH_OKTA_SECRET,
AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
```
#### Modify user `id` handling logic
### Step 4: Update Documentation (Optional)
The `user.id` is used to identify users. When introducing a new OAuth identity provider, you need to handle the information carried in the OAuth callback in `src/app/api/auth/next-auth.ts`. You need to select the user's `id` from this information. Before that, we need to understand the data processing sequence of `Auth.js`:
Add provider documentation in `docs/self-hosting/advanced/auth.mdx` and `docs/self-hosting/advanced/auth.zh-CN.mdx`.
```txt
authorize --> jwt --> session
```
## Adding a Built-in Provider
By default, in the `jwt --> session` process, `Auth.js` will [automatically assign the user `id` to `account.providerAccountId` based on the login type](https://authjs.dev/reference/core/types#provideraccountid). If you need to select a different value as the user `id`, you need to implement the following handling logic:
For providers natively supported by Better Auth (e.g., Discord), the steps differ slightly:
### Step 1: Create Provider Definition File
```ts
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
// You can select a different value from `account` or `profile`
token.userId = account.providerAccountId;
}
return token;
import { authEnv } from '@/envs/auth';
import type { BuiltinProviderDefinition } from '../types';
const provider: BuiltinProviderDefinition<{
AUTH_DISCORD_ID: string;
AUTH_DISCORD_SECRET: string;
}> = {
build: (env) => ({
clientId: env.AUTH_DISCORD_ID,
clientSecret: env.AUTH_DISCORD_SECRET,
}),
checkEnvs: () => {
return !!(authEnv.AUTH_DISCORD_ID && authEnv.AUTH_DISCORD_SECRET)
? {
AUTH_DISCORD_ID: authEnv.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: authEnv.AUTH_DISCORD_SECRET,
}
: false;
},
},
id: 'discord',
type: 'builtin',
};
export default provider;
```
#### Customize `session` return
### Step 2: Update Constants File
If you want to carry more information about `profile` and `account` in the `session`, according to the data processing order mentioned above in `Auth.js`, you must first copy this information to the `token`. For example, add the user avatar URL `profile.picture` to the `session`:
```diff
callbacks: {
async jwt({ token, profile, account }) {
if (profile && account) {
token.userId = account.providerAccountId;
+ token.avatar = profile.picture;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.userId ?? session.user.id;
+ session.user.avatar = token.avatar;
}
return session;
},
},
```
Then supplement the type definition for the new parameters:
Add to `src/libs/better-auth/constants.ts`:
```ts
declare module '@auth/core/jwt' {
interface JWT {
// ...
avatar?: string;
}
}
declare module 'next-auth' {
interface User {
avatar?: string;
}
}
export const BUILTIN_BETTER_AUTH_PROVIDERS = [
'apple',
'google',
'github',
'cognito',
'microsoft',
'discord', // Add new provider
] as const;
```
> [More built-in type extensions in Auth.js](https://authjs.dev/getting-started/typescript#module-augmentation)
## Callback URL Format
#### Differentiate multiple authentication providers in the processing logic
When configuring OAuth applications, use these callback URL formats:
If you have configured multiple authentication providers and their `userId` mappings are different, you can use the `account.provider` parameter in the `jwt` method to get the default id of the identity provider and enter different processing logic.
- **Built-in providers**: `https://yourdomain.com/api/auth/callback/{providerId}`
- **Generic OIDC**: `https://yourdomain.com/api/auth/callback/{providerId}`
```ts
callbacks: {
async jwt({ token, profile, account }) {
if (profile && account) {
if (account.provider === 'authing')
token.userId = account.providerAccountId ?? token.sub;
else if (acount.provider === 'auth0')
token.userId = profile.sub ?? token.sub;
else
// other providers
}
return token;
},
}
## Using the New Provider
After configuring environment variables, enable in `AUTH_SSO_PROVIDERS`:
```bash
AUTH_SSO_PROVIDERS=google,github,okta
AUTH_OKTA_ID=your-client-id
AUTH_OKTA_SECRET=your-client-secret
AUTH_OKTA_ISSUER=https://your-domain.okta.com
```
Now, you can use Okta as your provider to implement the authentication feature in LobeChat.
## Debugging Tips
1. **Environment variable check fails**: Ensure all required environment variables are set
2. **Callback URL errors**: Verify the callback URL configured in your OAuth application
3. **User profile mapping**: Use `mapProfileToUser` to customize the mapping from OAuth profile to user info
## Related Files
| File | Description |
| ----------------------------------------- | -------------------------------- |
| `src/libs/better-auth/sso/providers/*.ts` | Provider definitions |
| `src/libs/better-auth/sso/index.ts` | Provider registration |
| `src/libs/better-auth/sso/types.ts` | Type definitions |
| `src/libs/better-auth/sso/helpers.ts` | Helper functions |
| `src/libs/better-auth/constants.ts` | Built-in provider constants |
| `src/envs/auth.ts` | Environment variable definitions |
| `src/libs/better-auth/define-config.ts` | Better Auth configuration |

View file

@ -1,185 +1,195 @@
---
title: 新身份验证方式开发指南
title: 新身份验证提供商开发指南
---
# 新身份验证方式开发指南
# 新身份验证提供商开发指南
LobeChat 使用 [Auth.js v5](https://authjs.dev/) 作为外部身份验证服务。Auth.js 是一个开源的身份验证库,它提供了一种简单的方式来实现身份验证和授权功能。本文档将介绍如何使用 Auth.js 来实现新的身份验证方式
LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证服务。本文档介绍如何添加新的 SSO 身份验证提供商
## 添加新的身份验证提供者
## 架构概述
为了在 LobeChat 中添加新的身份验证提供者(例如添加 Okta),你需要完成以下步骤
Better Auth SSO 提供商分为两类
### 准备工作:查阅官方的提供者列表
| 类型 | 说明 | 示例 |
| --------- | -------------------------- | ----------------------------- |
| `builtin` | Better Auth 内置支持的提供商 | Google、GitHub、Microsoft、Apple |
| `generic` | 通过 Generic OIDC/OAuth 插件实现 | Okta、Auth0、Keycloak 等 |
首先,你需要查阅 [Auth.js 提供者列表](https://authjs.dev/reference/core/providers) 来了解是否你的提供者已经被支持。如果你的提供者已经被支持,你可以直接使用 Auth.js 提供的 SDK 来实现身份验证功能。
## 添加新的 SSO 提供商
接下来我会以 [Okta](https://authjs.dev/reference/core/providers/okta) 为例来介绍如何添加新的身份验证提供者
以添加 **Okta** 为例,介绍添加 `generic` 类型提供商的完整步骤。
### 步骤 1: 新增关键代码
### 步骤 1: 创建提供商定义文件
打开 `src/app/api/auth/next-auth.ts` 文件,引入 `next-auth/providers/okta`
在 `src/libs/better-auth/sso/providers/` 目录下创建 `okta.ts`
```ts
import { NextAuth } from 'next-auth';
import Auth0 from 'next-auth/providers/auth0';
import Okta from 'next-auth/providers/okta';
import { authEnv } from '@/envs/auth';
// 引入 Okta 提供者
```
import { buildOidcConfig } from '../helpers';
import type { GenericProviderDefinition } from '../types';
新增预定义的服务端配置
```ts
// 导入服务器配置
const { OKTA_CLIENT_ID, OKTA_CLIENT_SECRET, OKTA_ISSUER } = getServerConfig();
const nextAuth = NextAuth({
providers: [
// ... 其他提供者
Okta({
clientId: OKTA_CLIENT_ID,
clientSecret: OKTA_CLIENT_SECRET,
issuer: OKTA_ISSUER,
const provider: GenericProviderDefinition<{
AUTH_OKTA_ID: string;
AUTH_OKTA_ISSUER: string;
AUTH_OKTA_SECRET: string;
}> = {
// 构建 OIDC 配置
build: (env) =>
buildOidcConfig({
clientId: env.AUTH_OKTA_ID,
clientSecret: env.AUTH_OKTA_SECRET,
issuer: env.AUTH_OKTA_ISSUER,
overrides: {
// 可选:自定义用户信息映射
mapProfileToUser: (profile) => ({
email: profile.email,
name: profile.name ?? profile.preferred_username ?? profile.email ?? profile.sub,
}),
},
providerId: 'okta',
}),
],
});
```
### 步骤 2: 更新服务端配置代码
// 环境变量检查
checkEnvs: () => {
return !!(authEnv.AUTH_OKTA_ID && authEnv.AUTH_OKTA_SECRET && authEnv.AUTH_OKTA_ISSUER)
? {
AUTH_OKTA_ID: authEnv.AUTH_OKTA_ID,
AUTH_OKTA_ISSUER: authEnv.AUTH_OKTA_ISSUER,
AUTH_OKTA_SECRET: authEnv.AUTH_OKTA_SECRET,
}
: false;
},
打开 `src/config/server/app.ts` 文件,在 `getAppConfig` 函数中新增 Okta 相关的环境变量
```ts
export const getAppConfig = () => {
// ... 其他代码
return {
// ... 其他环境变量
OKTA_CLIENT_ID: process.env.OKTA_CLIENT_ID || '',
OKTA_CLIENT_SECRET: process.env.OKTA_CLIENT_SECRET || '',
OKTA_ISSUER: process.env.OKTA_ISSUER || '',
};
// 提供商 ID用于 AUTH_SSO_PROVIDERS 配置)
id: 'okta',
type: 'generic',
};
export default provider;
```
### 步骤 3: 修改前端页面
### 步骤 2: 注册提供商
修改在 `src/features/Conversation/Error/OAuthForm.tsx` 及 `src/app/settings/common/Common.tsx` 中的 `signIn` 函数参数
默认为 `auth0`,你可以将其修改为 `okta` 以切换到 Okta 提供者,或删除该参数以支持所有已添加的身份验证服务
该值为 Auth.js 提供者 的 id你可以阅读相应的 `next-auth/providers` 模块源码以读取默认 ID
### 步骤 4: 配置环境变量
在部署时新增 Okta 相关的环境变量 `OKTA_CLIENT_ID`、`OKTA_CLIENT_SECRET`、`OKTA_ISSUER`,并填入相应的值,即可使用
### 步骤 5: 修改服务端用户信息处理逻辑
#### 在前端获取用户信息
在前端页面中使用 `useOAuthSession()` 方法获取后端返回的用户信息 `user`
在 `src/libs/better-auth/sso/index.ts` 中导入并注册:
```ts
import { useOAuthSession } from '@/hooks/useOAuthSession';
// 导入提供商
import Okta from './providers/okta';
const { user, isOAuthLoggedIn } = useOAuthSession();
// 添加到 providerDefinitions 数组
const providerDefinitions = [
// ... 其他提供商
Okta,
] as const;
```
默认的 `user` 类型为 `User`,类型定义为:
### 步骤 3: 添加环境变量类型声明
在 `src/envs/auth.ts` 中添加类型声明:
```ts
interface User {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
// ProcessEnv 接口中添加
AUTH_OKTA_ID?: string;
AUTH_OKTA_SECRET?: string;
AUTH_OKTA_ISSUER?: string;
// getAuthConfig server schema 中添加
AUTH_OKTA_ID: z.string().optional(),
AUTH_OKTA_SECRET: z.string().optional(),
AUTH_OKTA_ISSUER: z.string().optional(),
// runtimeEnv 中添加
AUTH_OKTA_ID: process.env.AUTH_OKTA_ID,
AUTH_OKTA_SECRET: process.env.AUTH_OKTA_SECRET,
AUTH_OKTA_ISSUER: process.env.AUTH_OKTA_ISSUER,
```
#### 修改用户 `id` 处理逻辑
### 步骤 4: 更新文档(可选)
`user.id` 用于标识用户。当引入新身份 OAuth 提供者后,您需要在 `src/app/api/auth/next-auth.ts` 中处理 OAuth 回调所携带的信息。您需要从中选取用户的 `id`。在此之前,我们需要了解 `Auth.js` 的数据处理顺序:
在 `docs/self-hosting/advanced/auth.mdx` 和 `docs/self-hosting/advanced/auth.zh-CN.mdx` 中添加提供商文档。
```txt
authorize --> jwt --> session
```
## 添加内置提供商
默认情况下,在 `jwt --> session` 过程中,`Auth.js` 会[自动根据登陆类型](https://authjs.dev/reference/core/types#provideraccountid)将用户 `id` 赋值到 `account.providerAccountId` 中。 如果您需要选取其他值作为用户 `id` ,您需要实现以下处理逻辑。
如果要添加 Better Auth 内置支持的提供商(如 Discord步骤略有不同
### 步骤 1: 创建提供商定义文件
```ts
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
// 您可以从 `account` 或 `profile` 中选取其他值
token.userId = account.providerAccountId;
}
return token;
},
import { authEnv } from '@/envs/auth';
import type { BuiltinProviderDefinition } from '../types';
const provider: BuiltinProviderDefinition<{
AUTH_DISCORD_ID: string;
AUTH_DISCORD_SECRET: string;
}> = {
build: (env) => ({
clientId: env.AUTH_DISCORD_ID,
clientSecret: env.AUTH_DISCORD_SECRET,
}),
checkEnvs: () => {
return !!(authEnv.AUTH_DISCORD_ID && authEnv.AUTH_DISCORD_SECRET)
? {
AUTH_DISCORD_ID: authEnv.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: authEnv.AUTH_DISCORD_SECRET,
}
: false;
},
id: 'discord',
type: 'builtin',
};
export default provider;
```
#### 自定义 `session` 返回
### 步骤 2: 更新常量文件
如果您想在 `session` 中携带更多关于 `profile` 及 `account` 的信息,根据上面提到的 `Auth.js` 数据处理顺序,那必须先将该信息复制到 `token` 上。示例:把用户头像 URL`profile.picture` 添加到`session` 中:
```diff
callbacks: {
async jwt({ token, profile, account }) {
if (profile && account) {
token.userId = account.providerAccountId;
+ token.avatar = profile.picture;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.userId ?? session.user.id;
+ session.user.avatar = token.avatar;
}
return session;
},
},
```
然后补充对新增参数的类型定义:
在 `src/libs/better-auth/constants.ts` 中添加:
```ts
declare module '@auth/core/jwt' {
interface JWT {
// ...
avatar?: string;
}
}
declare module 'next-auth' {
interface User {
avatar?: string;
}
}
export const BUILTIN_BETTER_AUTH_PROVIDERS = [
'apple',
'google',
'github',
'cognito',
'microsoft',
'discord', // 新增
] as const;
```
> [更多`Auth.js`内置类型拓展](https://authjs.dev/getting-started/typescript#module-augmentation)
## 回调 URL 格式
#### 在处理逻辑中区分多个身份验证提供者
配置 OAuth 应用时,回调 URL 格式为:
如果您配置了多个身份验证提供者,并且他们的 `userId` 映射各不相同,可以在 `jwt` 方法中的 `account.provider` 参数获取身份提供者的默认 id ,从而进入不同的处理逻辑。
- **内置提供商**`https://yourdomain.com/api/auth/callback/{providerId}`
- **Generic OIDC**`https://yourdomain.com/api/auth/callback/{providerId}`
```ts
callbacks: {
async jwt({ token, profile, account }) {
if (profile && account) {
if (account.provider === 'Authing')
token.userId = account.providerAccountId ?? token.sub;
else if (acount.provider === 'Okta')
token.userId = profile.sub ?? token.sub;
else
// other providers
}
return token;
},
}
## 使用新提供商
配置环境变量后,在 `AUTH_SSO_PROVIDERS` 中启用:
```bash
AUTH_SSO_PROVIDERS=google,github,okta
AUTH_OKTA_ID=your-client-id
AUTH_OKTA_SECRET=your-client-secret
AUTH_OKTA_ISSUER=https://your-domain.okta.com
```
## 调试技巧
1. **环境变量检查失败**:确保所有必需的环境变量都已设置
2. **回调 URL 错误**:检查 OAuth 应用配置的回调 URL 是否正确
3. **用户信息映射**:通过 `mapProfileToUser` 自定义从 OAuth profile 到用户信息的映射
## 相关文件
| 文件 | 说明 |
| ----------------------------------------- | -------------- |
| `src/libs/better-auth/sso/providers/*.ts` | 提供商定义 |
| `src/libs/better-auth/sso/index.ts` | 提供商注册 |
| `src/libs/better-auth/sso/types.ts` | 类型定义 |
| `src/libs/better-auth/sso/helpers.ts` | 辅助函数 |
| `src/libs/better-auth/constants.ts` | 内置提供商常量 |
| `src/envs/auth.ts` | 环境变量定义 |
| `src/libs/better-auth/define-config.ts` | Better Auth 配置 |

View file

@ -110,14 +110,15 @@ Used by email verification, password reset, and magic-link delivery. Two provide
Send emails via SMTP protocol, suitable for users with existing email services. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/).
| Environment Variable | Type | Description | Example |
| ------------------------ | -------- | ------------------------------------------------------- | ------------------- |
| `EMAIL_SERVICE_PROVIDER` | Optional | Set to `nodemailer` (default) | `nodemailer` |
| `SMTP_HOST` | Required | SMTP server hostname | `smtp.gmail.com` |
| `SMTP_PORT` | Required | SMTP server port (`587` for TLS, `465` for SSL) | `587` |
| `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587) | `false` |
| `SMTP_USER` | Required | SMTP auth username | `user@gmail.com` |
| `SMTP_PASS` | Required | SMTP auth password | `your-app-password` |
| Environment Variable | Type | Description | Example |
| ------------------------ | -------- | ----------------------------------------------------------------- | ---------------------- |
| `EMAIL_SERVICE_PROVIDER` | Optional | Set to `nodemailer` (default) | `nodemailer` |
| `SMTP_HOST` | Required | SMTP server hostname | `smtp.gmail.com` |
| `SMTP_PORT` | Required | SMTP server port (`587` for TLS, `465` for SSL) | `587` |
| `SMTP_SECURE` | Optional | `true` for SSL (port 465), `false` for TLS (port 587) | `false` |
| `SMTP_USER` | Required | SMTP auth username | `user@gmail.com` |
| `SMTP_PASS` | Required | SMTP auth password | `your-app-password` |
| `SMTP_FROM` | Optional | Sender address (required for AWS SES), defaults to `SMTP_USER` | `noreply@example.com` |
<Callout type={'warning'}>
When using Gmail, you must use an App Password instead of your account password. Generate one at [Google App Passwords](https://myaccount.google.com/apppasswords).
@ -127,11 +128,11 @@ Send emails via SMTP protocol, suitable for users with existing email services.
[Resend](https://resend.com/) is a modern email API service with simple setup, recommended for new users.
| Environment Variable | Type | Description | Example |
| ------------------------ | ----------- | ---------------------------------------- | --------------------------- |
| `EMAIL_SERVICE_PROVIDER` | Required | Set to `resend` | `resend` |
| `RESEND_API_KEY` | Required | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` |
| `RESEND_FROM` | Recommended | Sender address, must be a verified domain| `noreply@your-domain.com` |
| Environment Variable | Type | Description | Example |
| ------------------------ | ----------- | ----------------------------------------- | --------------------------- |
| `EMAIL_SERVICE_PROVIDER` | Required | Set to `resend` | `resend` |
| `RESEND_API_KEY` | Required | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` |
| `RESEND_FROM` | Recommended | Sender address, must be a verified domain | `noreply@your-domain.com` |
<Callout type={'info'}>
Before using Resend, you need to [verify your sending domain](https://resend.com/docs/dashboard/domains/introduction), otherwise emails can only be sent to your own address.
@ -139,9 +140,9 @@ Send emails via SMTP protocol, suitable for users with existing email services.
### Common Configuration
| Environment Variable | Type | Description | Example |
| ------------------------- | -------- | -------------------------------------------------------- | ------- |
| `AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification (off by default)| `1` |
| Environment Variable | Type | Description | Example |
| ------------------------- | -------- | --------------------------------------------------------- | ------- |
| `AUTH_EMAIL_VERIFICATION` | Optional | Set to `1` to require email verification (off by default) | `1` |
## Magic Link (Passwordless) Login
@ -155,6 +156,19 @@ Enable magic-link login (depends on a working email provider above, off by defau
Go to [Environment Variables](/docs/self-hosting/environment-variables/auth#better-auth) for detailed information on all Better Auth variables.
</Callout>
## Session Storage Configuration (Optional)
By default, Better Auth uses the database to store session data. You can configure Redis as secondary storage for better performance and cross-instance session sharing.
| Environment Variable | Type | Description |
| -------------------- | -------- | ------------------------------------------------------------ |
| `REDIS_URL` | Optional | Redis connection URL, enables Redis session storage when set |
| `REDIS_PREFIX` | Optional | Redis key prefix, defaults to `lobechat` |
<Callout type={'info'}>
When Redis is configured, authentication session data will be stored in Redis, enabling session sharing across multiple service instances and faster session validation. See [Redis Cache Service](/docs/self-hosting/advanced/redis) for detailed configuration.
</Callout>
## FAQ
### What SSO providers does Better Auth support?
@ -164,3 +178,16 @@ Better Auth supports built-in providers (Google, GitHub, Microsoft, Apple, AWS C
### How do I enable multiple SSO providers?
Set the `AUTH_SSO_PROVIDERS` environment variable with a comma-separated list, e.g., `google,github,microsoft`. The order determines the display order on the login page.
### What if Casdoor users only have username without email?
The current authentication system requires email. Please configure a valid email address for users in Casdoor. Using a real, valid email is strongly recommended, otherwise features like password reset and magic link login will not work.
### How do I restrict registration to specific emails or domains?
Set the `AUTH_ALLOWED_EMAILS` environment variable with a comma-separated list of allowed emails or domains. For example:
- Allow only `example.com` domain: `AUTH_ALLOWED_EMAILS=example.com`
- Allow multiple domains and specific emails: `AUTH_ALLOWED_EMAILS=example.com,company.org,admin@other.com`
Leave empty to allow all emails. This restriction applies to both email registration and SSO login.

View file

@ -107,14 +107,15 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解
使用 SMTP 协议发送邮件,适合已有邮箱服务的用户。参考 [Nodemailer SMTP 文档](https://nodemailer.com/smtp/)。
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------- | -- | ----------------------------------------------- | ------------------ |
| `EMAIL_SERVICE_PROVIDER` | 可选 | 设置为 `nodemailer`(默认值) | `nodemailer` |
| `SMTP_HOST` | 必选 | SMTP 服务器主机名 | `smtp.gmail.com` |
| `SMTP_PORT` | 必选 | SMTP 服务器端口TLS 通常为 `587`SSL 为 `465` | `587` |
| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465TLS 设置为 `false`(端口 587 | `false` |
| `SMTP_USER` | 必选 | SMTP 认证用户名 | `user@gmail.com` |
| `SMTP_PASS` | 必选 | SMTP 认证密码 | `your-app-password`|
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------ | -- | ---------------------------------------------- | ---------------------- |
| `EMAIL_SERVICE_PROVIDER` | 可选 | 设置为 `nodemailer`(默认值) | `nodemailer` |
| `SMTP_HOST` | 必选 | SMTP 服务器主机名 | `smtp.gmail.com` |
| `SMTP_PORT` | 必选 | SMTP 服务器端口TLS 通常为 `587`SSL 为 `465` | `587` |
| `SMTP_SECURE` | 可选 | SSL 设置为 `true`(端口 465TLS 设置为 `false`(端口 587 | `false` |
| `SMTP_USER` | 必选 | SMTP 认证用户名 | `user@gmail.com` |
| `SMTP_PASS` | 必选 | SMTP 认证密码 | `your-app-password` |
| `SMTP_FROM` | 可选 | 发件人地址AWS SES 必填),默认为 `SMTP_USER` | `noreply@example.com` |
<Callout type={'warning'}>
使用 Gmail 时,需使用应用专用密码而非账户密码。前往 [Google 应用专用密码](https://myaccount.google.com/apppasswords) 生成。
@ -124,11 +125,11 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解
[Resend](https://resend.com/) 是一个现代邮件 API 服务,配置简单,推荐新用户使用。
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------- | -- | ---------------------------------- | --------------------------- |
| `EMAIL_SERVICE_PROVIDER` | 必选 | 设置为 `resend` | `resend` |
| `RESEND_API_KEY` | 必选 | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` |
| `RESEND_FROM` | 推荐 | 发件人地址,需为 Resend 已验证域名下的邮箱 | `noreply@your-domain.com` |
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------ | -- | ------------------------- | --------------------------- |
| `EMAIL_SERVICE_PROVIDER` | 必选 | 设置为 `resend` | `resend` |
| `RESEND_API_KEY` | 必选 | Resend API Key | `re_xxxxxxxxxxxxxxxxxxxxxx` |
| `RESEND_FROM` | 推荐 | 发件人地址,需为 Resend 已验证域名下的邮箱 | `noreply@your-domain.com` |
<Callout type={'info'}>
使用 Resend 前需先 [验证发件域名](https://resend.com/docs/dashboard/domains/introduction),否则只能发送到自己的邮箱。
@ -136,9 +137,9 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解
### 通用配置
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------- | -- | ---------------------------- | -- |
| `AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱(默认关闭) | `1`|
| 环境变量 | 类型 | 描述 | 示例 |
| ------------------------- | -- | --------------------------- | --- |
| `AUTH_EMAIL_VERIFICATION` | 可选 | 设置为 `1` 以要求用户在登录前验证邮箱(默认关闭) | `1` |
## 魔法链接(免密)登录
@ -152,6 +153,19 @@ LobeChat 使用 [Better Auth](https://www.better-auth.com) 作为身份验证解
前往 [环境变量](/zh/docs/self-hosting/environment-variables/auth#better-auth) 可查阅所有 Better Auth 相关变量详情。
</Callout>
## 会话存储配置(可选)
默认情况下Better Auth 使用数据库存储会话数据。你可以配置 Redis 作为二级存储,以获得更好的性能和跨实例会话共享能力。
| 环境变量 | 类型 | 描述 |
| -------------- | -- | ------------------------------- |
| `REDIS_URL` | 可选 | Redis 连接 URL配置后自动启用 Redis 会话存储 |
| `REDIS_PREFIX` | 可选 | Redis 键前缀,默认为 `lobechat` |
<Callout type={'info'}>
配置 Redis 后,认证会话数据将存储在 Redis 中,可以实现跨多个服务实例的会话共享,并提升会话验证速度。详细配置请参阅 [Redis 缓存服务](/zh/docs/self-hosting/advanced/redis)。
</Callout>
## 常见问题
### Better Auth 支持哪些 SSO 提供商?
@ -161,3 +175,17 @@ Better Auth 支持内置提供商Google、GitHub、Microsoft、Apple、AWS Co
### 如何启用多个 SSO 提供商?
设置 `AUTH_SSO_PROVIDERS` 环境变量,使用逗号分隔多个提供商,例如 `google,github,microsoft`。顺序决定登录页面上的显示顺序。
### Casdoor 用户只有 username 没有 email 怎么办?
当前身份验证方案强依赖 email。请在 Casdoor 中为用户配置有效的 email 地址。
强烈建议使用真实有效的邮箱,否则密码重置、魔法链接登录等功能将无法使用。
### 如何限制只允许特定邮箱或域名注册?
设置 `AUTH_ALLOWED_EMAILS` 环境变量,支持完整邮箱地址或域名,以逗号分隔。例如:
- 只允许 `example.com` 域名:`AUTH_ALLOWED_EMAILS=example.com`
- 允许多个域名和特定邮箱:`AUTH_ALLOWED_EMAILS=example.com,company.org,admin@other.com`
留空表示允许所有邮箱注册。此限制对邮箱注册和 SSO 登录均有效。

View file

@ -36,6 +36,10 @@ By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CL
## Next Auth
<Callout type={'tip'}>
To migrate from NextAuth to Better Auth, see the [NextAuth Migration Guide](/docs/self-hosting/advanced/auth/nextauth-to-betterauth).
</Callout>
Before using NextAuth, please set the following variables in LobeChat's environment variables:
| Environment Variable | Type | Description |

View file

@ -34,6 +34,10 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供安全、便捷的
## Next Auth
<Callout type={'tip'}>
如需从 NextAuth 迁移到 Better Auth请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/advanced/auth/nextauth-to-betterauth)。
</Callout>
在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量:
| 环境变量 | 类型 | 描述 |

View file

@ -0,0 +1,326 @@
---
title: Migrating from NextAuth to Better Auth
description: >-
Guide for migrating your LobeChat deployment from NextAuth authentication to
Better Auth, including simple and full migration options.
tags:
- Authentication Service
- Better Auth
- NextAuth
- Migration
---
# Migrating from NextAuth to Better Auth
This guide helps you migrate your existing NextAuth-based LobeChat deployment to Better Auth.
<Callout type={'info'}>
Better Auth is the recommended authentication solution for LobeChat. It offers simpler configuration, more SSO providers, and better self-hosting support.
</Callout>
<Callout type={'error'}>
**Important Notice**:
- **Always backup your database first!** For Neon users, create a backup via [Fork Branch](https://neon.tech/docs/manage/branches#create-a-branch)
- LobeChat is not responsible for any data loss or issues that may occur during the migration process
- This guide is intended for users with development experience; not recommended for users without technical background
- If you have any questions, feel free to ask in our [Discord](https://discord.com/invite/AYFPHvv2jT) community or [GitHub Issue](https://github.com/lobehub/lobe-chat/issues)
</Callout>
## Choose Your Migration Path
| Method | Best For | User Impact | Data Preserved |
| ------------------------------------- | ------------------------------ | ---------------------- | ------------------------------------ |
| [Simple Migration](#simple-migration) | Small deployments (≤ 10 users) | Users need to re-login | Chat history, settings |
| [Full Migration](#full-migration) | Large deployments | Seamless for users | Everything including SSO connections |
<Callout type={'info'}>
**Note**: NextAuth never supported email/password login, so there are no password hashes to migrate. The main benefit of full migration is preserving SSO connections (Google, GitHub, etc.).
</Callout>
## Environment Variable Migration Reference
### General Variables
| NextAuth (Old) | Better Auth (New) | Notes |
| -------------------------------- | -------------------- | -------------------------------------------------------- |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | *(Deprecated)* | No longer needed, Better Auth auto-enables with database |
| `NEXT_AUTH_SECRET` | `AUTH_SECRET` | Session encryption key |
| `AUTH_URL` | `APP_URL` | Application URL (for OAuth callbacks) |
| `NEXT_AUTH_SSO_PROVIDERS` | `AUTH_SSO_PROVIDERS` | SSO provider list (comma-separated) |
| `NEXT_AUTH_SSO_SESSION_STRATEGY` | *(Deprecated)* | No longer needed, Better Auth uses DB sessions |
### SSO Provider Variables
SSO provider environment variables follow the same format: `AUTH_<PROVIDER>_ID` and `AUTH_<PROVIDER>_SECRET`.
| NextAuth (Old) | Better Auth (New) | Notes |
| -------------------------------- | ----------------------- | ------------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ Unchanged |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ Unchanged |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ Unchanged |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ Unchanged |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ Unchanged |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ Unchanged |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ Unchanged |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ Unchanged |
| `microsoft-entra-id` | `microsoft` | ⚠️ Provider renamed |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ Variable renamed |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ Variable renamed |
<Callout type={'warning'}>
**Note**: Microsoft Entra ID provider name changed from `microsoft-entra-id` to `microsoft`, and the environment variable prefix changed from `AUTH_MICROSOFT_ENTRA_ID_` to `AUTH_MICROSOFT_`.
</Callout>
### New Feature Variables
Better Auth supports additional features with these new environment variables:
| Environment Variable | Description |
| ------------------------- | ----------------------------------------- |
| `AUTH_ALLOWED_EMAILS` | Email whitelist (restrict registration) |
| `AUTH_EMAIL_VERIFICATION` | Enable email verification (set to `1`) |
| `ENABLE_MAGIC_LINK` | Enable magic link login (set to `1`) |
| `EMAIL_SERVICE_PROVIDER` | Email provider (`nodemailer` or `resend`) |
## Simple Migration
For small self-hosted deployments, the simplest approach is to let users re-login with their SSO accounts.
<Callout type={'warning'}>
**Limitation**: This method loses SSO connection data. Use [Full Migration](#full-migration) to preserve SSO connections.
Although SSO connections will be lost, users can manually re-bind social accounts through the profile page after logging in.
**Example scenario**: If your previous account had two SSO accounts linked:
- Primary email (Google): `mail1@google.com`
- Secondary email (Microsoft): `mail2@outlook.com`
After migrating, logging in with `mail2@outlook.com` will create a **new user** instead of linking to your existing account.
</Callout>
### Steps
1. **Update Environment Variables**
Remove NextAuth variables and add Better Auth variables:
```bash
# Remove these
# NEXT_AUTH_SECRET=xxx
# AUTH_xxx related NextAuth provider configs
# Add these
AUTH_SECRET=your-secret-key # openssl rand -base64 32
# Optional: Enable Google SSO (example)
AUTH_SSO_PROVIDERS=google
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
```
<Callout type={'tip'}>
See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup.
</Callout>
2. **Redeploy LobeChat**
Deploy the new version with Better Auth enabled.
3. **Notify Users**
Inform users to log in again with their previous SSO account. Chat history and settings will be preserved since user IDs remain the same.
<Callout type={'tip'}>
This method is quick and requires minimal setup. Users just need to re-login with their existing SSO provider.
</Callout>
## Full Migration
For larger deployments or when you need to preserve SSO connections, use the migration script. This migrates data from the `nextauth_accounts` table to the Better Auth `accounts` table.
<Callout type={'error'}>
**Important Notice**:
- **Always backup your database first!** For Neon users, create a backup via [Fork Branch](https://neon.tech/docs/manage/branches#create-a-branch)
- Migration scripts must be **run locally after cloning the repository**, not in the deployment environment
- Due to the high-risk nature of user data migration, **we do not provide automatic migration during deployment**
- Always use dry-run mode first to verify the script runs successfully before executing
- Always verify in a test environment before operating on production database
</Callout>
### Prerequisites
**Environment Requirements:**
- Node.js 18+
- Git (for cloning the repository)
- pnpm (for installing dependencies)
**Preparation:**
1. Clone the LobeChat repository and install dependencies:
```bash
git clone https://github.com/lobehub/lobe-chat.git
cd lobe-chat
pnpm install
```
2. Prepare the database connection string
3. Ensure database schema is up to date
<Callout type={'info'}>
If you've been on an older version for a while, your database schema may be outdated. Run this in the cloned repository:
```bash
DATABASE_URL=your-database-url pnpm db:migrate
```
</Callout>
### Step 1: Configure Migration Script Environment Variables
Create a `.env` file in the project root (the script will automatically load it) with all environment variables:
```bash
# ============================================
# Migration mode: test or prod
# Recommended: Start with test mode to verify on a test database,
# then switch to prod after confirming everything works
# ============================================
NEXTAUTH_TO_BETTERAUTH_MODE=test
# ============================================
# Database connection (uses corresponding variable based on mode)
# TEST_ prefix for test environment, PROD_ prefix for production
# ============================================
TEST_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@test-host:5432/testdb
PROD_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@prod-host:5432/proddb
# ============================================
# Database driver (optional)
# neon: Neon serverless driver (default)
# node: node-postgres driver
# ============================================
NEXTAUTH_TO_BETTERAUTH_DATABASE_DRIVER=neon
# ============================================
# Batch size (optional)
# Number of records to process per batch, default is 300
# ============================================
NEXTAUTH_TO_BETTERAUTH_BATCH_SIZE=300
# ============================================
# Dry Run mode (optional)
# Set to 1 to only print logs without modifying the database
# Recommended: Enable for first run, disable after verification
# ============================================
NEXTAUTH_TO_BETTERAUTH_DRY_RUN=1
```
### Step 2: Dry-Run Verification (Test Environment)
```bash
# Run migration (NEXTAUTH_TO_BETTERAUTH_DRY_RUN=1, only logs without modifying database)
npx tsx scripts/nextauth-to-betterauth/index.ts
```
Review the output logs, confirm no issues, then proceed to the next step.
### Step 3: Execute Migration and Verify (Test Environment)
Update `.env` to set `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` to `0`, then execute:
```bash
# Execute migration
npx tsx scripts/nextauth-to-betterauth/index.ts
# Verify the migration
npx tsx scripts/nextauth-to-betterauth/verify.ts
```
After verifying the test environment migration is successful, proceed to the next step.
### Step 4: Dry-Run Verification (Production Environment)
1. Update `.env` file:
- Change `NEXTAUTH_TO_BETTERAUTH_MODE` to `prod`
- Change `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` back to `1`
2. Run the script:
```bash
# Run migration (dry-run mode to verify)
npx tsx scripts/nextauth-to-betterauth/index.ts
```
Review the output logs, confirm no issues, then proceed to the next step.
### Step 5: Execute Migration and Verify (Production Environment)
Update `.env` to set `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` to `0`, then execute:
```bash
# Execute migration
npx tsx scripts/nextauth-to-betterauth/index.ts
# Verify the migration
npx tsx scripts/nextauth-to-betterauth/verify.ts
```
### Step 6: Configure Better Auth and Redeploy
After migration is complete, follow [Simple Migration - Step 1](#steps) to configure Better Auth environment variables and redeploy.
<Callout type={'tip'}>
For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth), including all supported SSO providers and email service configuration.
</Callout>
## What Gets Migrated
| Data | Simple Migration | Full Migration |
| -------------------------------------- | ---------------- | -------------- |
| User accounts | ✅ (via re-login) | ✅ |
| SSO connections (Google, GitHub, etc.) | ❌ | ✅ |
| Chat history | ✅ | ✅ |
| User settings | ✅ | ✅ |
<Callout type={'info'}>
**Note**: Sessions and verification tokens are not migrated as they are temporary data. Users will need to log in again after migration.
</Callout>
## Troubleshooting
### Users can't log in after migration
- Check that `AUTH_SECRET` is set correctly
- Verify database connection is working
- Ensure SSO provider is configured in `AUTH_SSO_PROVIDERS`
### SSO users can't connect
- For simple migration: Users need to log in again with their SSO account
- For full migration: Verify the SSO provider is configured in `AUTH_SSO_PROVIDERS` with the same provider ID
### Migration script fails
- Check database connection string
- Review script logs for specific errors
- Ensure the `nextauth_accounts` table exists in your database
### column "xxx" of relation "users" does not exist
This error occurs because the database schema is outdated. Run `pnpm db:migrate` to update the database structure before running the migration script.
## Related Reading
<Cards>
<Card href={'/docs/self-hosting/advanced/auth'} title={'Authentication Service Configuration'} />
<Card href={'/docs/self-hosting/environment-variables/auth'} title={'Auth Environment Variables'} />
<Card href={'/docs/self-hosting/advanced/auth/legacy'} title={'Legacy Authentication (NextAuth & Clerk)'} />
</Cards>

View file

@ -0,0 +1,323 @@
---
title: 从 NextAuth 迁移到 Better Auth
description: 将 LobeChat 部署从 NextAuth 身份验证迁移到 Better Auth 的指南,包括简单迁移和完整迁移选项。
tags:
- 身份验证服务
- Better Auth
- NextAuth
- 迁移
---
# 从 NextAuth 迁移到 Better Auth
本指南帮助您将现有的基于 NextAuth 的 LobeChat 部署迁移到 Better Auth。
<Callout type={'info'}>
Better Auth 是 LobeChat 推荐的身份验证解决方案。它提供更简单的配置、更多的 SSO 提供商支持,以及更好的自托管体验。
</Callout>
<Callout type={'error'}>
**重要提醒**
- **务必先备份数据库**!如使用 Neon可通过 [Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建备份
- 迁移过程中可能出现的任何数据丢失或问题LobeChat 概不负责
- 本指南适合有一定开发背景的用户,不建议无技术经验的用户自行操作
- 如有任何疑问,欢迎到 [Discord](https://discord.com/invite/AYFPHvv2jT) 社区或 [GitHub Issue](https://github.com/lobehub/lobe-chat/issues) 提问
</Callout>
## 选择迁移方式
| 方式 | 适用场景 | 用户影响 | 数据保留 |
| ------------- | ------------- | ------- | ------------- |
| [简单迁移](#简单迁移) | 小型部署(≤ 10 用户) | 用户需重新登录 | 聊天记录、设置 |
| [完整迁移](#完整迁移) | 大型部署 | 对用户无感知 | 全部数据包括 SSO 连接 |
<Callout type={'info'}>
**注意**NextAuth 从未支持邮箱密码登录,因此没有密码哈希需要迁移。完整迁移的主要好处是保留 SSO 连接Google、GitHub 等)。
</Callout>
## 环境变量迁移对照表
### 通用变量
| NextAuth (旧) | Better Auth (新) | 说明 |
| -------------------------------- | -------------------- | ---------------------------- |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | *(已废弃)* | 不再需要Better Auth 在配置数据库后自动启用 |
| `NEXT_AUTH_SECRET` | `AUTH_SECRET` | 会话加密密钥 |
| `AUTH_URL` | `APP_URL` | 应用 URL用于 OAuth 回调) |
| `NEXT_AUTH_SSO_PROVIDERS` | `AUTH_SSO_PROVIDERS` | SSO 提供商列表(逗号分隔) |
| `NEXT_AUTH_SSO_SESSION_STRATEGY` | *(已废弃)* | 不再需要Better Auth 使用数据库会话 |
### SSO 提供商变量
SSO 提供商的环境变量格式保持一致:`AUTH_<PROVIDER>_ID` 和 `AUTH_<PROVIDER>_SECRET`。
| NextAuth (旧) | Better Auth (新) | 说明 |
| -------------------------------- | ----------------------- | ---------------- |
| `AUTH_GITHUB_ID` | `AUTH_GITHUB_ID` | ✅ 保持不变 |
| `AUTH_GITHUB_SECRET` | `AUTH_GITHUB_SECRET` | ✅ 保持不变 |
| `AUTH_GOOGLE_ID` | `AUTH_GOOGLE_ID` | ✅ 保持不变 |
| `AUTH_GOOGLE_SECRET` | `AUTH_GOOGLE_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ID` | `AUTH_AUTH0_ID` | ✅ 保持不变 |
| `AUTH_AUTH0_SECRET` | `AUTH_AUTH0_SECRET` | ✅ 保持不变 |
| `AUTH_AUTH0_ISSUER` | `AUTH_AUTH0_ISSUER` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ID` | `AUTH_AUTHENTIK_ID` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_SECRET` | `AUTH_AUTHENTIK_SECRET` | ✅ 保持不变 |
| `AUTH_AUTHENTIK_ISSUER` | `AUTH_AUTHENTIK_ISSUER` | ✅ 保持不变 |
| `microsoft-entra-id` | `microsoft` | ⚠️ provider 名称变更 |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | `AUTH_MICROSOFT_ID` | ⚠️ 变量名变更 |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | `AUTH_MICROSOFT_SECRET` | ⚠️ 变量名变更 |
<Callout type={'warning'}>
**注意**Microsoft Entra ID 的 provider 名称从 `microsoft-entra-id` 改为 `microsoft`,相应的环境变量前缀也从 `AUTH_MICROSOFT_ENTRA_ID_` 改为 `AUTH_MICROSOFT_`。
</Callout>
### 新增功能变量
Better Auth 支持更多功能,以下是新增的环境变量:
| 环境变量 | 说明 |
| ------------------------- | -------------------------------- |
| `AUTH_ALLOWED_EMAILS` | 邮箱白名单(限制注册) |
| `AUTH_EMAIL_VERIFICATION` | 启用邮箱验证(设为 `1` |
| `ENABLE_MAGIC_LINK` | 启用魔法链接登录(设为 `1` |
| `EMAIL_SERVICE_PROVIDER` | 邮件服务提供商(`nodemailer` 或 `resend` |
## 简单迁移
对于小型自托管部署,最简单的方法是让用户使用 SSO 账户重新登录。
<Callout type={'warning'}>
**限制**:此方法会丢失 SSO 连接数据。如需保留 SSO 连接,请使用 [完整迁移](#完整迁移)。
虽然 SSO 连接会丢失,但用户可以在登录后通过个人资料页手动重新绑定社交账号。
**示例场景**:假设你之前的账户绑定了两个 SSO 账户:
- 主邮箱Google`mail1@google.com`
- 副邮箱Microsoft`mail2@outlook.com`
迁移后使用 `mail2@outlook.com` 登录将会**创建新用户**,而非关联到原有账户。
</Callout>
### 步骤
1. **更新环境变量**
移除 NextAuth 变量并添加 Better Auth 变量:
```bash
# 移除这些
# NEXT_AUTH_SECRET=xxx
# AUTH_xxx 相关的 NextAuth 提供商配置
# 添加这些
AUTH_SECRET=your-secret-key # openssl rand -base64 32
# 可选:启用 Google SSO示例
AUTH_SSO_PROVIDERS=google
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
```
<Callout type={'tip'}>
查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。
</Callout>
2. **重新部署 LobeChat**
部署启用 Better Auth 的新版本。
3. **通知用户**
告知用户使用之前的 SSO 账户重新登录。由于用户 ID 保持不变,聊天记录和设置将被保留。
<Callout type={'tip'}>
这种方法快速且配置简单。用户只需使用现有的 SSO 提供商重新登录即可。
</Callout>
## 完整迁移
对于大型部署或需要保留 SSO 连接的情况,请使用迁移脚本。这会将数据从 `nextauth_accounts` 表迁移到 Better Auth 的 `accounts` 表。
<Callout type={'error'}>
**重要说明**
- **务必先备份数据库**!如使用 Neon可通过 [Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建备份
- 迁移脚本需要 **clone 仓库后在本地运行**,不是在部署环境中执行
- 由于迁移涉及用户数据,风险较高,**官方不提供部署时自动迁移功能**
- 请务必先使用 dry-run 模式测试脚本能够顺利运行再正式执行
- 请务必在测试环境验证后再操作生产数据库
</Callout>
### 前置条件
**环境要求:**
- Node.js 18+
- Git用于 clone 仓库)
- pnpm用于安装依赖
**准备工作:**
1. Clone LobeChat 仓库并安装依赖:
```bash
git clone https://github.com/lobehub/lobe-chat.git
cd lobe-chat
pnpm install
```
2. 准备数据库连接字符串
3. 确保数据库 schema 为最新版本
<Callout type={'info'}>
如果你长期停留在旧版本,数据库 schema 可能不是最新的。请在 clone 的仓库中运行:
```bash
DATABASE_URL=your-database-url pnpm db:migrate
```
</Callout>
### 步骤 1配置迁移脚本环境变量
在项目根目录创建 `.env` 文件(脚本会自动加载),配置所有环境变量:
```bash
# ============================================
# 迁移模式test 或 prod
# 建议先用 test 模式在测试数据库验证,确认无误后再切换到 prod
# ============================================
NEXTAUTH_TO_BETTERAUTH_MODE=test
# ============================================
# 数据库连接(根据模式使用对应的环境变量)
# TEST_ 前缀用于测试环境PROD_ 前缀用于生产环境
# ============================================
TEST_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@test-host:5432/testdb
PROD_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL=postgresql://user:pass@prod-host:5432/proddb
# ============================================
# 数据库驱动(可选)
# neon: Neon serverless 驱动(默认)
# node: node-postgres 驱动
# ============================================
NEXTAUTH_TO_BETTERAUTH_DATABASE_DRIVER=neon
# ============================================
# 批处理大小(可选)
# 每批处理的记录数,默认为 300
# ============================================
NEXTAUTH_TO_BETTERAUTH_BATCH_SIZE=300
# ============================================
# Dry Run 模式(可选)
# 设为 1 时只打印日志,不实际修改数据库
# 建议首次运行时启用,验证无误后再关闭
# ============================================
NEXTAUTH_TO_BETTERAUTH_DRY_RUN=1
```
### 步骤 2Dry-Run 验证(测试环境)
```bash
# 运行迁移NEXTAUTH_TO_BETTERAUTH_DRY_RUN=1只打印日志不修改数据库
npx tsx scripts/nextauth-to-betterauth/index.ts
```
检查输出日志,确认无异常后继续下一步。
### 步骤 3执行迁移并验证测试环境
修改 `.env` 将 `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` 改为 `0`,然后执行:
```bash
# 执行迁移
npx tsx scripts/nextauth-to-betterauth/index.ts
# 验证迁移结果
npx tsx scripts/nextauth-to-betterauth/verify.ts
```
验证测试环境迁移结果无误后,继续下一步。
### 步骤 4Dry-Run 验证(生产环境)
1. 修改 `.env` 文件:
- 将 `NEXTAUTH_TO_BETTERAUTH_MODE` 改为 `prod`
- 将 `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` 改回 `1`
2. 运行脚本:
```bash
# 运行迁移dry-run 模式验证)
npx tsx scripts/nextauth-to-betterauth/index.ts
```
检查输出日志,确认无异常后继续下一步。
### 步骤 5执行迁移并验证生产环境
修改 `.env` 将 `NEXTAUTH_TO_BETTERAUTH_DRY_RUN` 改为 `0`,然后执行:
```bash
# 执行迁移
npx tsx scripts/nextauth-to-betterauth/index.ts
# 验证迁移结果
npx tsx scripts/nextauth-to-betterauth/verify.ts
```
### 步骤 6配置 Better Auth 并重新部署
迁移完成后,参照 [简单迁移 - 步骤 1](#步骤) 配置 Better Auth 环境变量并重新部署。
<Callout type={'tip'}>
完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。
</Callout>
## 迁移内容对比
| 数据 | 简单迁移 | 完整迁移 |
| ----------------------- | --------- | ---- |
| 用户账户 | ✅(通过重新登录) | ✅ |
| SSO 连接Google、GitHub 等) | ❌ | ✅ |
| 聊天记录 | ✅ | ✅ |
| 用户设置 | ✅ | ✅ |
<Callout type={'info'}>
**注意**Sessions 和 verification tokens 不会被迁移,因为它们是临时数据。迁移后用户需要重新登录。
</Callout>
## 常见问题
### 迁移后用户无法登录
- 检查 `AUTH_SECRET` 是否正确设置
- 验证数据库连接是否正常
- 确保 SSO 提供商已在 `AUTH_SSO_PROVIDERS` 中配置
### SSO 用户无法连接
- 简单迁移:用户需要使用 SSO 账户重新登录
- 完整迁移:验证 SSO 提供商已在 `AUTH_SSO_PROVIDERS` 中配置,且使用相同的 provider ID
### 迁移脚本失败
- 检查数据库连接字符串
- 查看脚本日志了解具体错误
- 确保数据库中存在 `nextauth_accounts` 表
### column "xxx" of relation "users" does not exist
这是因为数据库 schema 未更新。请先运行 `pnpm db:migrate` 更新数据库结构,然后再执行迁移脚本。
## 相关阅读
<Cards>
<Card href={'/zh/docs/self-hosting/advanced/auth'} title={'身份验证服务配置'} />
<Card href={'/zh/docs/self-hosting/environment-variables/auth'} title={'认证相关环境变量'} />
<Card href={'/zh/docs/self-hosting/advanced/auth/legacy'} title={'旧版身份验证NextAuth 和 Clerk'} />
</Cards>

View file

@ -0,0 +1,128 @@
---
title: Configure Redis Cache Service
description: Learn how to configure Redis cache service to optimize LobeChat performance and session management.
tags:
- Redis
- Cache
- Session Storage
- Performance
---
# Configure Redis Cache Service
LobeChat uses Redis as a high-performance cache and session storage service to optimize system performance and manage user authentication state.
<Callout type={'info'}>
LobeChat uses the standard Redis protocol (via ioredis library), supporting any Redis
protocol-compatible service, including official Redis, self-hosted Redis, and cloud provider Redis
services (such as AWS ElastiCache, Alibaba Cloud Redis, etc.).
</Callout>
## Use Cases
Redis is used in LobeChat for the following scenarios:
### Authentication Session Storage
As secondary storage for Better Auth, used to store user authentication sessions and token data. This enables:
- Sharing session state across multiple service instances
- Faster session validation
- Session revocation and management support
### File Proxy Cache
Caches S3 presigned URLs to reduce S3 API calls and optimize file access performance.
### Agent Configuration Cache
Caches Agent configuration data to reduce database queries and improve response speed.
## Core Environment Variables
<Steps>
### `REDIS_URL`
The Redis server connection URL. This is required to enable Redis functionality.
```shell
REDIS_URL=redis://localhost:6379
```
Supported URL formats:
- Standard: `redis://localhost:6379`
- With authentication: `redis://username:password@localhost:6379`
- With database: `redis://localhost:6379/0`
### `REDIS_PREFIX`
The prefix for Redis keys, used to isolate LobeChat data in a shared Redis instance.
- Default: `lobechat`
- Example: `REDIS_PREFIX=my-lobechat`
### `REDIS_TLS`
Whether to enable TLS/SSL encrypted connection.
- Default: `false`
- Example: `REDIS_TLS=true`
<Callout type={'tip'}>
If you use Redis services from cloud providers, you usually need to enable TLS to ensure secure
data transmission.
</Callout>
### `REDIS_PASSWORD`
Redis authentication password (optional). Set this if your Redis server is configured with password authentication.
### `REDIS_USERNAME`
Redis authentication username (optional). Redis 6.0+ supports ACL user authentication. Set this if using username authentication.
### `REDIS_DATABASE`
Redis database index (optional). Redis supports multiple databases (default 0-15), you can specify which database to use.
- Default: `0`
- Example: `REDIS_DATABASE=1`
</Steps>
## Configuration Examples
### Local Development
```shell
REDIS_URL=redis://localhost:6379
REDIS_PREFIX=lobechat-dev
```
### Production (with authentication)
```shell
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=your-strong-password
REDIS_PREFIX=lobechat
REDIS_TLS=true
```
### Cloud Service (e.g., AWS ElastiCache)
```shell
REDIS_URL=redis://your-cluster.cache.amazonaws.com:6379
REDIS_TLS=true
REDIS_PREFIX=lobechat
```
## Notes
<Callout type={'warning'}>
Redis is an optional service. If `REDIS_URL` is not configured, LobeChat will still function
normally, but will lose the caching and session management optimizations mentioned above.
</Callout>
- **Memory Management**: Redis is an in-memory database, ensure your server has sufficient memory
- **Persistence**: Enable Redis RDB or AOF persistence to prevent data loss
- **High Availability**: For production, consider using Redis Sentinel or Redis Cluster for high availability

View file

@ -0,0 +1,126 @@
---
title: 配置 Redis 缓存服务
description: 了解如何配置 Redis 缓存服务以优化 LobeChat 的性能和会话管理。
tags:
- Redis
- 缓存
- 会话存储
- 性能优化
---
# 配置 Redis 缓存服务
LobeChat 使用 Redis 作为高性能缓存和会话存储服务,用于优化系统性能和管理用户认证状态。
<Callout type={'info'}>
LobeChat 使用标准 Redis 协议(通过 ioredis 库),支持任何兼容 Redis 协议的服务,包括 Redis
官方服务、自部署 Redis、以及云服务商提供的 Redis 服务(如 AWS ElastiCache、阿里云 Redis
等)。
</Callout>
## 使用场景
Redis 在 LobeChat 中主要用于以下场景:
### 认证会话存储
作为 Better Auth 的二级存储,用于存储用户认证 session 和 token 数据。这可以实现:
- 跨多个服务实例共享会话状态
- 更快的会话验证速度
- 支持会话撤销和管理
### 文件代理缓存
缓存 S3 预签名 URL减少对 S3 API 的调用次数,优化文件访问性能。
### Agent 配置缓存
缓存 Agent 配置数据,减少数据库查询,提升响应速度。
## 核心环境变量
<Steps>
### `REDIS_URL`
Redis 服务器的连接 URL这是启用 Redis 功能的必需配置。
```shell
REDIS_URL=redis://localhost:6379
```
支持的 URL 格式:
- 标准格式:`redis://localhost:6379`
- 带认证:`redis://username:password@localhost:6379`
- 带数据库:`redis://localhost:6379/0`
### `REDIS_PREFIX`
Redis 键的前缀,用于在共享 Redis 实例中隔离 LobeChat 的数据。
- 默认值:`lobechat`
- 示例:`REDIS_PREFIX=my-lobechat`
### `REDIS_TLS`
是否启用 TLS/SSL 加密连接。
- 默认值:`false`
- 示例:`REDIS_TLS=true`
<Callout type={'tip'}>
如果你使用云服务商提供的 Redis 服务,通常需要启用 TLS 以确保数据传输安全。
</Callout>
### `REDIS_PASSWORD`
Redis 认证密码(可选)。如果 Redis 服务器配置了密码认证,需要设置此变量。
### `REDIS_USERNAME`
Redis 认证用户名可选。Redis 6.0+ 支持 ACL 用户认证,如果使用了用户名认证,需要设置此变量。
### `REDIS_DATABASE`
Redis 数据库索引可选。Redis 支持多个数据库(默认 0-15可以指定使用的数据库。
- 默认值:`0`
- 示例:`REDIS_DATABASE=1`
</Steps>
## 配置示例
### 本地开发
```shell
REDIS_URL=redis://localhost:6379
REDIS_PREFIX=lobechat-dev
```
### 生产环境(带认证)
```shell
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=your-strong-password
REDIS_PREFIX=lobechat
REDIS_TLS=true
```
### 云服务(如 AWS ElastiCache
```shell
REDIS_URL=redis://your-cluster.cache.amazonaws.com:6379
REDIS_TLS=true
REDIS_PREFIX=lobechat
```
## 注意事项
<Callout type={'warning'}>
Redis 是可选服务。如果不配置 `REDIS_URL`LobeChat 仍然可以正常运行,但会失去上述缓存和会话管理的优化功能。
</Callout>
- **内存管理**Redis 是内存数据库,请确保服务器有足够的内存
- **持久化**:建议启用 Redis 的 RDB 或 AOF 持久化,防止数据丢失
- **高可用**:生产环境建议使用 Redis Sentinel 或 Redis Cluster 实现高可用

View file

@ -0,0 +1,69 @@
---
title: Configuring Upstash Redis Service
description: Step-by-step guide to configure Upstash Redis for LobeChat cache and session storage.
tags:
- Upstash
- Redis
- Cache
- Configuration Guide
---
# Configuring Upstash Redis Service
[Upstash](https://upstash.com/) is a serverless Redis service that provides a free tier and pay-as-you-go pricing, making it ideal for LobeChat deployments.
## Configuration Steps
<Steps>
### Create Redis Database on Upstash
1. Visit [Upstash Console](https://console.upstash.com/) and sign up
2. Click **Create Database** and configure: name, region, enable TLS
3. Copy the **Redis URL** (TCP connection, not REST API) from the database details page:
<Image alt={'Copy Redis URL from Upstash'} src={'https://hub-apac-1.lobeobjects.space/docs/43d110283ba816c0c2b45408e4f9d344.png'} />
### Configure Environment Variables
```shell
# Upstash Redis URL (copy from Upstash console)
REDIS_URL=rediss://default:xxxxxxxxxxxxx@us1-xxxxx-xxxxx.upstash.io:6379
# Optional: Enable TLS (recommended for Upstash)
REDIS_TLS=1
# Optional: Set a prefix for Redis keys
REDIS_PREFIX=lobechat
```
<Callout type={'info'}>
Upstash uses `rediss://` (with double 's') for TLS connections. LobeChat supports this format automatically.
</Callout>
</Steps>
## Environment Variables Overview
```shell
# Upstash Redis Connection URL
REDIS_URL=rediss://default:xxxxxxxxxxxxx@us1-xxxxx-xxxxx.upstash.io:6379
# Optional: Enable TLS encryption (recommended for Upstash)
REDIS_TLS=1
# Optional: Key prefix for data isolation
REDIS_PREFIX=lobechat
```
## Notes
<Callout type={'tip'}>
Upstash offers a generous free tier with 10,000 commands per day, which is sufficient for personal use and small deployments.
</Callout>
<Callout type={'warning'}>
Make sure to keep your Redis URL secure and never expose it in client-side code or public repositories.
</Callout>
- **Free Tier Limits**: 10,000 commands/day, 256MB storage
- **TLS Required**: Upstash requires TLS connections, ensure `REDIS_TLS=1` is set
- **Regional vs Global**: Choose Global for better latency if your users are distributed worldwide

View file

@ -0,0 +1,69 @@
---
title: 配置 Upstash Redis 服务
description: 详细指南:如何配置 Upstash Redis 用于 LobeChat 的缓存和会话存储。
tags:
- Upstash
- Redis
- 缓存
- 配置指南
---
# 配置 Upstash Redis 服务
[Upstash](https://upstash.com/) 是一个 Serverless Redis 服务,提供免费额度和按量付费模式,非常适合 LobeChat 部署使用。
## 配置步骤
<Steps>
### 在 Upstash 创建 Redis 数据库
1. 访问 [Upstash 控制台](https://console.upstash.com/) 并注册
2. 点击 **Create Database**,配置:名称、区域、启用 TLS
3. 从数据库详情页复制 **Redis URL**TCP 连接方式,不是 REST API
<Image alt={'从 Upstash 复制 Redis URL'} src={'https://hub-apac-1.lobeobjects.space/docs/43d110283ba816c0c2b45408e4f9d344.png'} />
### 配置环境变量
```shell
# Upstash Redis URL从 Upstash 控制台复制)
REDIS_URL=rediss://default:xxxxxxxxxxxxx@us1-xxxxx-xxxxx.upstash.io:6379
# 可选:启用 TLSUpstash 推荐)
REDIS_TLS=1
# 可选:设置 Redis 键前缀
REDIS_PREFIX=lobechat
```
<Callout type={'info'}>
Upstash 使用 `rediss://`(双 's')表示 TLS 连接LobeChat 自动支持此格式。
</Callout>
</Steps>
## 环境变量概览
```shell
# Upstash Redis 连接 URL
REDIS_URL=rediss://default:xxxxxxxxxxxxx@us1-xxxxx-xxxxx.upstash.io:6379
# 可选:启用 TLS 加密Upstash 推荐)
REDIS_TLS=1
# 可选:键前缀,用于数据隔离
REDIS_PREFIX=lobechat
```
## 注意事项
<Callout type={'tip'}>
Upstash 提供慷慨的免费额度:每天 10,000 次命令,足够个人使用和小规模部署。
</Callout>
<Callout type={'warning'}>
请确保安全保管你的 Redis URL切勿在客户端代码或公开仓库中暴露。
</Callout>
- **免费额度限制**:每天 10,000 次命令256MB 存储
- **TLS 必需**Upstash 要求 TLS 连接,确保设置 `REDIS_TLS=1`
- **Regional vs Global**:如果用户分布全球,选择 Global 可获得更低延迟

View file

@ -27,7 +27,7 @@ LobeChat provides a complete authentication service capability when deployed. Th
- Default: `-`
- Example: `Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
#### `AUTH_EMAIL_VERIFICATION`
- Type: Optional
- Description: Set to `1` to require email verification before users can sign in. Users must verify their email address after registration.
@ -41,6 +41,13 @@ LobeChat provides a complete authentication service capability when deployed. Th
- Default: `-`
- Example: `google,github,microsoft,cognito`
#### `AUTH_ALLOWED_EMAILS`
- Type: Optional
- Description: Comma-separated list of allowed emails or domains for registration. Supports full email addresses (e.g., `user@example.com`) or domain names (e.g., `example.com`). Leave empty to allow all emails.
- Default: `-`
- Example: `example.com,admin@other.com`
#### `JWKS_KEY`
- Type: Required
@ -95,6 +102,13 @@ These settings are required for email verification and password reset features.
- Default: `-`
- Example: `your-app-specific-password`
#### `SMTP_FROM`
- Type: Optional
- Description: Sender email address. Required for AWS SES where `SMTP_USER` is not a valid email address. If not set, defaults to `SMTP_USER`.
- Default: Value of `SMTP_USER`
- Example: `noreply@example.com`
### Google
#### `AUTH_GOOGLE_ID`

View file

@ -25,7 +25,7 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
- 默认值:`-`
- 示例:`Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
#### `AUTH_EMAIL_VERIFICATION`
- 类型:可选
- 描述:设置为 `1` 以要求用户在登录前验证邮箱。用户注册后必须验证邮箱地址。
@ -39,6 +39,13 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
- 默认值:`-`
- 示例:`google,github,microsoft,cognito`
#### `AUTH_ALLOWED_EMAILS`
- 类型:可选
- 描述:允许注册的邮箱或域名白名单,以逗号分隔。支持完整邮箱地址(如 `user@example.com`)或域名(如 `example.com`)。留空表示允许所有邮箱。
- 默认值:`-`
- 示例:`example.com,admin@other.com`
#### `JWKS_KEY`
- 类型:必选
@ -93,6 +100,13 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
- 默认值:`-`
- 示例:`your-app-specific-password`
#### `SMTP_FROM`
- 类型:可选
- 描述发件人邮箱地址。AWS SES 等服务需要此配置(因为 `SMTP_USER` 不是有效邮箱地址)。若未设置,默认使用 `SMTP_USER`。
- 默认值:`SMTP_USER` 的值
- 示例:`noreply@example.com`
### Google
#### `AUTH_GOOGLE_ID`

View file

@ -19,6 +19,19 @@ LobeChat provides some additional configuration options during deployment, which
## Common Variables
### `KEY_VAULTS_SECRET`
- Type: Required (server database mode)
- Description: Used to encrypt sensitive information stored by users in the database (such as API Keys, baseURL, etc.), preventing exposure of critical information in case of database breach
- Default: -
- Example: `Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=`
<Callout type={'warning'}>
This key is used to encrypt sensitive data. Once set, do not change it, otherwise encrypted data cannot be decrypted.
</Callout>
<GenerateSecret envName="KEY_VAULTS_SECRET" />
### `API_KEY_SELECT_MODE`
- TypeOptional

View file

@ -16,6 +16,19 @@ LobeChat 在部署时提供了一些额外的配置项,你可以使用环境
## 通用变量
### `KEY_VAULTS_SECRET`
- 类型:必选(服务端数据库模式)
- 描述:用于加密用户存储在数据库中的敏感信息(如 API Key、baseURL 等),防止数据库泄露时关键信息被暴露
- 默认值:-
- 示例:`Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=`
<Callout type={'warning'}>
此密钥用于加密敏感数据,一旦设置后请勿更改,否则已加密的数据将无法解密。
</Callout>
<GenerateSecret envName="KEY_VAULTS_SECRET" />
### `API_KEY_SELECT_MODE`
- 类型:可选

View file

@ -0,0 +1,68 @@
---
title: Configure Redis Cache Service
description: Learn how to configure Redis cache service to optimize performance and session management.
tags:
- Redis
- Cache
- Session Storage
- Environment Variables
---
# Configure Redis Cache Service
LobeChat uses Redis as a high-performance cache and session storage service. Configuring Redis can optimize authentication session management, file proxy caching, and more.
## Core Environment Variables
### `REDIS_URL`
- Type: Optional
- Description: Redis server connection URL
- Default: -
- Example: `redis://localhost:6379`
Supported URL formats:
- Standard: `redis://localhost:6379`
- With authentication: `redis://username:password@localhost:6379`
- With database: `redis://localhost:6379/0`
### `REDIS_PREFIX`
- Type: Optional
- Description: Prefix for Redis keys, used to isolate data in a shared Redis instance
- Default: `lobechat`
- Example: `my-lobechat`
### `REDIS_TLS`
- Type: Optional
- Description: Whether to enable TLS/SSL encrypted connection
- Default: `false`
- Example: `true`
<Callout type={'tip'}>
Redis services from cloud providers usually require TLS enabled to ensure secure data
transmission.
</Callout>
### `REDIS_PASSWORD`
- Type: Optional
- Description: Redis authentication password
- Default: -
- Example: `your-password`
### `REDIS_USERNAME`
- Type: Optional
- Description: Redis authentication username (Redis 6.0+ ACL authentication)
- Default: -
- Example: `default`
### `REDIS_DATABASE`
- Type: Optional
- Description: Redis database index (0-15)
- Default: `0`
- Example: `1`

View file

@ -0,0 +1,67 @@
---
title: 配置 Redis 缓存服务
description: 了解如何配置 Redis 缓存服务以优化性能和会话管理。
tags:
- Redis
- 缓存
- 会话存储
- 环境变量
---
# 配置 Redis 缓存服务
LobeChat 使用 Redis 作为高性能缓存和会话存储服务。配置 Redis 可以优化认证会话管理、文件代理缓存等功能。
## 核心环境变量
### `REDIS_URL`
- 类型:可选
- 描述Redis 服务器的连接 URL
- 默认值:-
- 示例:`redis://localhost:6379`
支持的 URL 格式:
- 标准格式:`redis://localhost:6379`
- 带认证:`redis://username:password@localhost:6379`
- 带数据库:`redis://localhost:6379/0`
### `REDIS_PREFIX`
- 类型:可选
- 描述Redis 键的前缀,用于在共享 Redis 实例中隔离数据
- 默认值:`lobechat`
- 示例:`my-lobechat`
### `REDIS_TLS`
- 类型:可选
- 描述:是否启用 TLS/SSL 加密连接
- 默认值:`false`
- 示例:`true`
<Callout type={'tip'}>
云服务商提供的 Redis 服务通常需要启用 TLS 以确保数据传输安全。
</Callout>
### `REDIS_PASSWORD`
- 类型:可选
- 描述Redis 认证密码
- 默认值:-
- 示例:`your-password`
### `REDIS_USERNAME`
- 类型:可选
- 描述Redis 认证用户名Redis 6.0+ ACL 认证)
- 默认值:-
- 示例:`default`
### `REDIS_DATABASE`
- 类型:可选
- 描述Redis 数据库索引0-15
- 默认值:`0`
- 示例:`1`

View file

@ -18,18 +18,18 @@ This document outlines the breaking changes introduced in LobeHub 2.0 and provid
The following environment variables have been removed in LobeHub 2.0:
| Environment Variable | Removal Reason |
| ----------------------------------- | ----------------------------------------------------------------- |
| `ACCESS_CODE` | No longer supported, use Better Auth authentication system |
| `NEXT_PUBLIC_SERVICE_MODE` | 2.0 only supports Server DB mode, Client DB (PGlite) removed |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | Automatically detected via `AUTH_SECRET` presence |
| `NEXT_PUBLIC_AUTH_URL` / `AUTH_URL` | Automatically detected from request headers |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | NextAuth removed |
| `NEXT_AUTH_SECRET` | NextAuth removed |
| `NEXT_AUTH_SSO_PROVIDERS` | NextAuth removed |
| `NEXTAUTH_URL` | NextAuth removed |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk removed |
| `CLERK_SECRET_KEY` | Clerk removed |
| Environment Variable | Removal Reason |
| ----------------------------------- | ------------------------------------------------------------ |
| `ACCESS_CODE` | No longer supported, use Better Auth authentication system |
| `NEXT_PUBLIC_SERVICE_MODE` | 2.0 only supports Server DB mode, Client DB (PGlite) removed |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | Automatically detected via `AUTH_SECRET` presence |
| `NEXT_PUBLIC_AUTH_URL` / `AUTH_URL` | Automatically detected from request headers |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | NextAuth removed |
| `NEXT_AUTH_SECRET` | NextAuth removed |
| `NEXT_AUTH_SSO_PROVIDERS` | NextAuth removed |
| `NEXTAUTH_URL` | NextAuth removed |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk removed |
| `CLERK_SECRET_KEY` | Clerk removed |
## New Required Environment Variables
@ -40,15 +40,15 @@ LobeHub 2.0 only supports Better Auth authentication system. The following envir
## New Optional Environment Variables
| Environment Variable | Description |
| ---------------------------------- | ---------------------------------------------------------------- |
| `AUTH_SSO_PROVIDERS` | Comma-separated list of enabled SSO providers |
| `INTERNAL_JWT_EXPIRATION` | Internal JWT token expiration time (default: `30s`) |
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | Set to `1` to require email verification |
| `SMTP_HOST` | SMTP server hostname for email features |
| `SMTP_PORT` | SMTP server port |
| `SMTP_USER` | SMTP authentication username |
| `SMTP_PASS` | SMTP authentication password |
| Environment Variable | Description |
| ------------------------- | --------------------------------------------------- |
| `AUTH_SSO_PROVIDERS` | Comma-separated list of enabled SSO providers |
| `INTERNAL_JWT_EXPIRATION` | Internal JWT token expiration time (default: `30s`) |
| `AUTH_EMAIL_VERIFICATION` | Set to `1` to require email verification |
| `SMTP_HOST` | SMTP server hostname for email features |
| `SMTP_PORT` | SMTP server port |
| `SMTP_USER` | SMTP authentication username |
| `SMTP_PASS` | SMTP authentication password |
For detailed configuration, see [Authentication Environment Variables](/docs/self-hosting/environment-variables/auth).
@ -60,11 +60,11 @@ LobeHub 2.0 only supports Better Auth authentication system. NextAuth and Clerk
### Migrating from NextAuth
Detailed migration guide coming soon.
See the [NextAuth Migration Guide](/docs/self-hosting/advanced/auth/nextauth-to-betterauth).
### Migrating from Clerk
Detailed migration guide coming soon.
See the [Clerk Migration Guide](/docs/self-hosting/advanced/auth/clerk-to-betterauth).
## Database Mode Changes

View file

@ -16,18 +16,18 @@ tags:
以下环境变量在 LobeHub 2.0 中已被移除:
| 环境变量 | 移除原因 |
| ----------------------------------- | ------------------------------------------------------- |
| `ACCESS_CODE` | 不再支持,请使用 Better Auth 认证系统 |
| `NEXT_PUBLIC_SERVICE_MODE` | 2.0 仅支持 Server DB 模式,不再支持 Client DB (PGlite) |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | 通过 `AUTH_SECRET` 是否存在自动检测 |
| `NEXT_PUBLIC_AUTH_URL` / `AUTH_URL` | 从请求头中自动检测 |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | NextAuth 已移除 |
| `NEXT_AUTH_SECRET` | NextAuth 已移除 |
| `NEXT_AUTH_SSO_PROVIDERS` | NextAuth 已移除 |
| `NEXTAUTH_URL` | NextAuth 已移除 |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk 已移除 |
| `CLERK_SECRET_KEY` | Clerk 已移除 |
| 环境变量 | 移除原因 |
| ----------------------------------- | -------------------------------------------- |
| `ACCESS_CODE` | 不再支持,请使用 Better Auth 认证系统 |
| `NEXT_PUBLIC_SERVICE_MODE` | 2.0 仅支持 Server DB 模式,不再支持 Client DB (PGlite) |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | 通过 `AUTH_SECRET` 是否存在自动检测 |
| `NEXT_PUBLIC_AUTH_URL` / `AUTH_URL` | 从请求头中自动检测 |
| `NEXT_PUBLIC_ENABLE_NEXT_AUTH` | NextAuth 已移除 |
| `NEXT_AUTH_SECRET` | NextAuth 已移除 |
| `NEXT_AUTH_SSO_PROVIDERS` | NextAuth 已移除 |
| `NEXTAUTH_URL` | NextAuth 已移除 |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk 已移除 |
| `CLERK_SECRET_KEY` | Clerk 已移除 |
## 新增的必需环境变量
@ -38,15 +38,15 @@ LobeHub 2.0 仅支持 Better Auth 认证系统。以下环境变量现在是必
## 新增的可选环境变量
| 环境变量 | 说明 |
| ------------------------------------- | -------------------------------------- |
| `AUTH_SSO_PROVIDERS` | 启用的 SSO 提供商列表,以逗号分隔 |
| `INTERNAL_JWT_EXPIRATION` | 内部 JWT 令牌过期时间(默认:`30s` |
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | 设置为 `1` 以要求邮箱验证 |
| `SMTP_HOST` | 邮件功能的 SMTP 服务器主机名 |
| `SMTP_PORT` | SMTP 服务器端口 |
| `SMTP_USER` | SMTP 认证用户名 |
| `SMTP_PASS` | SMTP 认证密码 |
| 环境变量 | 说明 |
| ------------------------- | ----------------------- |
| `AUTH_SSO_PROVIDERS` | 启用的 SSO 提供商列表,以逗号分隔 |
| `INTERNAL_JWT_EXPIRATION` | 内部 JWT 令牌过期时间(默认:`30s` |
| `AUTH_EMAIL_VERIFICATION` | 设置为 `1` 以要求邮箱验证 |
| `SMTP_HOST` | 邮件功能的 SMTP 服务器主机名 |
| `SMTP_PORT` | SMTP 服务器端口 |
| `SMTP_USER` | SMTP 认证用户名 |
| `SMTP_PASS` | SMTP 认证密码 |
详细配置请参阅[身份验证环境变量](/docs/self-hosting/environment-variables/auth)。
@ -58,11 +58,11 @@ LobeHub 2.0 仅支持 Better Auth 认证系统,不再支持 NextAuth 和 Clerk
### 从 NextAuth 迁移
详细迁移指南即将推出
请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/advanced/auth/nextauth-to-betterauth)
### 从 Clerk 迁移
详细迁移指南即将推出
请参阅 [Clerk 迁移指南](/zh/docs/self-hosting/advanced/auth/clerk-to-betterauth)
## 数据库模式变更

View file

@ -562,9 +562,9 @@ Next, modify the configuration files to achieve domain release.
# - 'APP_URL=http://localhost:3210'
- 'APP_URL=https://lobe.example.com'
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
# - 'AUTH_URL=http://localhost:${LOBE_PORT}/api/auth'
- 'AUTH_URL=https://lobe.example.com/api/auth'
@ -837,9 +837,9 @@ services:
# - 'APP_URL=http://localhost:3210'
- 'APP_URL=https://lobe.example.com'
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
# - 'AUTH_URL=http://localhost:${LOBE_PORT}/api/auth'
- 'AUTH_URL=https://lobe.example.com/api/auth'

View file

@ -538,9 +538,9 @@ docker compose up -d
# - 'APP_URL=http://localhost:3210'
- 'APP_URL=https://lobe.example.com'
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
# - 'AUTH_URL=http://localhost:${LOBE_PORT}/api/auth'
- 'AUTH_URL=https://lobe.example.com/api/auth'
@ -812,9 +812,9 @@ services:
# - 'APP_URL=http://localhost:3210'
- 'APP_URL=https://lobe.example.com'
- 'NEXT_AUTH_SSO_PROVIDERS=casdoor'
- 'AUTH_SSO_PROVIDERS=casdoor'
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
- 'NEXT_AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
# - 'AUTH_URL=http://localhost:${LOBE_PORT}/api/auth'
- 'AUTH_URL=https://lobe.example.com/api/auth'

View file

@ -298,12 +298,11 @@ HEADLESS=false pnpm exec cucumber-js --config cucumber.config.js --tags "@smoke"
运行测试需要以下环境变量:
```bash
BASE_URL=http://localhost:3010 # 测试服务器地址
DATABASE_URL=postgresql://... # 数据库连接
DATABASE_DRIVER=node # 数据库驱动
KEY_VAULTS_SECRET=... # 密钥
BETTER_AUTH_SECRET=... # Auth 密钥
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 # 启用 Better Auth
BASE_URL=http://localhost:3010 # 测试服务器地址
DATABASE_URL=postgresql://... # 数据库连接
DATABASE_DRIVER=node # 数据库驱动
KEY_VAULTS_SECRET=... # 密钥
AUTH_SECRET=... # Auth 密钥
# 可选S3 相关(如果测试涉及文件上传)
S3_ACCESS_KEY_ID=e2e-mock-access-key

View file

@ -98,8 +98,7 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
SKIP_LINT=1 \
bun run build
```
@ -112,8 +111,7 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0 \
S3_ACCESS_KEY_ID=e2e-mock-access-key \
S3_SECRET_ACCESS_KEY=e2e-mock-secret-key \
@ -126,14 +124,13 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
### 服务器启动环境变量
| 变量 | 值 | 说明 |
| ------------------------------------- | -------------------------------------------------------- | ---------------- |
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5433/postgres` | 数据库连接 |
| `DATABASE_DRIVER` | `node` | 数据库驱动 |
| `KEY_VAULTS_SECRET` | `LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=` | 密钥保险库密钥 |
| `BETTER_AUTH_SECRET` | `e2e-test-secret-key-for-better-auth-32chars!` | 认证密钥 |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | `1` | 启用 Better Auth |
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | `0` | 禁用邮箱验证 |
| 变量 | 值 | 说明 |
| ------------------------------------- | -------------------------------------------------------- | -------------- |
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5433/postgres` | 数据库连接 |
| `DATABASE_DRIVER` | `node` | 数据库驱动 |
| `KEY_VAULTS_SECRET` | `LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=` | 密钥保险库密钥 |
| `AUTH_SECRET` | `e2e-test-secret-key-for-better-auth-32chars!` | 认证密钥 |
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | `0` | 禁用邮箱验证 |
### S3 Mock 变量(必需)

View file

@ -31,26 +31,22 @@ const CONFIG = {
defaultPort: 3006,
dockerImage: 'paradedb/paradedb:latest',
projectRoot: resolve(__dirname, '../..'),
// S3 Mock (required even if not testing file uploads)
s3Mock: {
// S3 Mock (required even if not testing file uploads)
s3Mock: {
accessKeyId: 'e2e-mock-access-key',
bucket: 'e2e-mock-bucket',
endpoint: 'https://e2e-mock-s3.localhost',
secretAccessKey: 'e2e-mock-secret-key',
},
},
// 2 minutes
// Secrets (for e2e testing only)
secrets: {
// 2 minutes
// Secrets (for e2e testing only)
secrets: {
betterAuthSecret: 'e2e-test-secret-key-for-better-auth-32chars!',
keyVaultsSecret: 'LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=',
},
serverTimeout: 120_000,
};
@ -263,11 +259,10 @@ async function buildApp(): Promise<void> {
log('🔨', 'Building application (this may take a few minutes)...');
await execAsync('bun', ['run', 'build'], {
BETTER_AUTH_SECRET: CONFIG.secrets.betterAuthSecret,
AUTH_SECRET: CONFIG.secrets.betterAuthSecret,
DATABASE_DRIVER: CONFIG.databaseDriver,
DATABASE_URL: CONFIG.databaseUrl,
KEY_VAULTS_SECRET: CONFIG.secrets.keyVaultsSecret,
NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1',
SKIP_LINT: '1',
});
@ -289,12 +284,11 @@ async function isServerRunning(port: number): Promise<boolean> {
function getServerEnv(port: number): Record<string, string> {
return {
BETTER_AUTH_SECRET: CONFIG.secrets.betterAuthSecret,
AUTH_EMAIL_VERIFICATION: '0',
AUTH_SECRET: CONFIG.secrets.betterAuthSecret,
DATABASE_DRIVER: CONFIG.databaseDriver,
DATABASE_URL: CONFIG.databaseUrl,
KEY_VAULTS_SECRET: CONFIG.secrets.keyVaultsSecret,
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: '0',
NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1',
NODE_OPTIONS: '--max-old-space-size=6144',
PORT: String(port),
S3_ACCESS_KEY_ID: CONFIG.s3Mock.accessKeyId,

View file

@ -135,13 +135,14 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
...process.env,
// APP_URL is required for Better Auth to recognize localhost as a trusted origin
APP_URL: `http://localhost:${port}`,
// E2E test secret keys
BETTER_AUTH_SECRET: 'e2e-test-secret-key-for-better-auth-32chars!',
KEY_VAULTS_SECRET: 'LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=',
// Disable email verification for e2e
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: '0',
// Enable Better Auth for e2e tests with real authentication
NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1',
AUTH_EMAIL_VERIFICATION: '0',
// E2E test secret keys
AUTH_SECRET: 'e2e-test-secret-key-for-better-auth-32chars!',
KEY_VAULTS_SECRET: 'LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=',
NODE_OPTIONS: '--max-old-space-size=6144',
PORT: String(port),
// Mock S3 env vars to prevent initialization errors

View file

@ -146,7 +146,7 @@
"@aws-sdk/s3-request-presigner": "~3.932.0",
"@azure-rest/ai-inference": "1.0.0-beta.5",
"@azure/core-auth": "^1.10.1",
"@better-auth/expo": "1.4.17",
"@better-auth/expo": "^1.4.17",
"@better-auth/passkey": "^1.4.17",
"@cfworker/json-schema": "^4.1.1",
"@codesandbox/sandpack-react": "^2.20.0",
@ -224,7 +224,6 @@
"@trpc/react-query": "^11.8.1",
"@trpc/server": "^11.8.1",
"@upstash/qstash": "^2.8.4",
"@upstash/redis": "^1.35.8",
"@upstash/workflow": "^0.2.23",
"@vercel/analytics": "^1.6.1",
"@vercel/edge-config": "^1.4.3",
@ -238,7 +237,7 @@
"antd-style": "4.1.0",
"async-retry": "^1.3.3",
"bcryptjs": "^3.0.3",
"better-auth": "1.4.17",
"better-auth": "^1.4.17",
"better-auth-harmony": "^1.2.5",
"better-call": "^1.2.0",
"brotli-wasm": "^3.0.1",
@ -280,7 +279,6 @@
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"next": "^16.1.1",
"next-auth": "5.0.0-beta.30",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
@ -401,7 +399,7 @@
"@types/unist": "^3.0.3",
"@types/ws": "^8.18.1",
"@types/xast": "^2.0.4",
"@typescript/native-preview": "7.0.0-dev.20251210.1",
"@typescript/native-preview": "7.0.0-dev.20260122.4",
"@vitest/coverage-v8": "^3.2.4",
"ajv-keywords": "^5.1.0",
"code-inspector-plugin": "1.3.3",

View file

@ -1,8 +1,13 @@
import { boolean, integer, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { AdapterAccount } from 'next-auth/adapters';
import { users } from './user';
/**
* NextAuth account type (oauth, email, credentials, etc.)
* Previously imported from next-auth/adapters, now defined locally to remove dependency
*/
type AccountType = 'credentials' | 'email' | 'oauth' | 'oidc' | 'webauthn';
/**
* This table stores nextauth accounts. This is used to link users to their sso profiles.
* @see {@link https://authjs.dev/guides/creating-a-database-adapter#database-session-management | NextAuth Doc}
@ -19,7 +24,7 @@ export const nextauthAccounts = pgTable(
scope: text('scope'),
session_state: text('session_state'),
token_type: text('token_type'),
type: text('type').$type<AdapterAccount>().notNull(),
type: text('type').$type<AccountType>().notNull(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),

View file

@ -2,29 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { extractBearerToken, getUserAuth } from '../auth';
// Mock auth constants
let mockEnableBetterAuth = false;
let mockEnableNextAuth = false;
vi.mock('@/envs/auth', () => ({
get enableBetterAuth() {
return mockEnableBetterAuth;
},
get enableNextAuth() {
return mockEnableNextAuth;
},
}));
vi.mock('@/libs/next-auth', () => ({
default: {
auth: vi.fn().mockResolvedValue({
user: {
id: 'next-auth-user-id',
},
}),
},
}));
vi.mock('next/headers', () => ({
headers: vi.fn(() => new Headers()),
}));
@ -44,48 +21,9 @@ vi.mock('@/auth', () => ({
describe('getUserAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEnableBetterAuth = false;
mockEnableNextAuth = false;
});
it('should throw error when no auth method is enabled', async () => {
await expect(getUserAuth()).rejects.toThrow('Auth method is not enabled');
});
it('should return next auth when next auth is enabled', async () => {
mockEnableNextAuth = true;
const auth = await getUserAuth();
expect(auth).toEqual({
nextAuth: {
user: {
id: 'next-auth-user-id',
},
},
userId: 'next-auth-user-id',
});
});
it('should return better auth when better auth is enabled', async () => {
mockEnableBetterAuth = true;
const auth = await getUserAuth();
expect(auth).toEqual({
betterAuth: {
user: {
id: 'better-auth-user-id',
},
},
userId: 'better-auth-user-id',
});
});
it('should prioritize better auth over next auth when both are enabled', async () => {
mockEnableBetterAuth = true;
mockEnableNextAuth = true;
it('should return better auth session', async () => {
const auth = await getUserAuth();
expect(auth).toEqual({

View file

@ -1,34 +1,18 @@
import { headers } from 'next/headers';
import { enableBetterAuth, enableNextAuth } from '@/envs/auth';
import { auth } from '@/auth';
export const getUserAuth = async () => {
if (enableBetterAuth) {
const { auth: betterAuth } = await import('@/auth');
const currentHeaders = await headers();
const requestHeaders = Object.fromEntries(currentHeaders.entries());
const currentHeaders = await headers();
const requestHeaders = Object.fromEntries(currentHeaders.entries());
const session = await auth.api.getSession({
headers: requestHeaders,
});
const session = await betterAuth.api.getSession({
headers: requestHeaders,
});
const userId = session?.user?.id;
const userId = session?.user?.id;
return { betterAuth: session, userId };
}
if (enableNextAuth) {
const { default: NextAuth } = await import('@/libs/next-auth');
const session = await NextAuth.auth();
const userId = session?.user.id;
return { nextAuth: session, userId };
}
throw new Error('Auth method is not enabled');
return { betterAuth: session, userId };
};
/**

View file

@ -0,0 +1,99 @@
/**
* Shared utility to check for deprecated authentication environment variables.
* Used by both prebuild.mts (build time) and startServer.js (Docker runtime).
*
* IMPORTANT: Keep this file as CommonJS (.js) for compatibility with startServer.js
*/
const MIGRATION_DOC_BASE = 'https://lobehub.com/docs/self-hosting/advanced/auth';
/**
* Deprecated environment variable checks configuration
* @type {Array<{
* name: string;
* getVars: () => string[];
* message: string;
* docUrl?: string;
* formatVar?: (envVar: string) => string;
* }>}
*/
const DEPRECATED_CHECKS = [
{
docUrl: `${MIGRATION_DOC_BASE}/nextauth-to-betterauth`,
getVars: () =>
Object.keys(process.env).filter(
(key) => key.startsWith('NEXT_AUTH') || key.startsWith('NEXTAUTH'),
),
message: 'NextAuth has been removed from LobeChat. Please migrate to Better Auth.',
name: 'NextAuth',
},
{
docUrl: `${MIGRATION_DOC_BASE}/clerk-to-betterauth`,
getVars: () =>
['NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', 'CLERK_SECRET_KEY', 'CLERK_WEBHOOK_SECRET'].filter(
(key) => process.env[key],
),
message: 'Clerk has been removed from LobeChat. Please migrate to Better Auth.',
name: 'Clerk',
},
{
formatVar: (envVar) => {
const mapping = {
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: 'AUTH_EMAIL_VERIFICATION',
NEXT_PUBLIC_ENABLE_MAGIC_LINK: 'ENABLE_MAGIC_LINK',
};
return `${envVar} → Please use ${mapping[envVar]} instead`;
},
getVars: () =>
['NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION', 'NEXT_PUBLIC_ENABLE_MAGIC_LINK'].filter(
(key) => process.env[key],
),
message: 'Please update to the new environment variable names.',
name: 'Deprecated Auth',
},
];
/**
* Print error message and exit
*/
function printErrorAndExit(name, vars, message, action, docUrl, formatVar) {
console.error('\n' + '═'.repeat(70));
console.error(`❌ ERROR: ${name} environment variables are deprecated!`);
console.error('═'.repeat(70));
console.error('\nDetected deprecated environment variables:');
for (const envVar of vars) {
console.error(`${formatVar ? formatVar(envVar) : envVar}`);
}
console.error(`\n${message}`);
if (docUrl) {
console.error(`\n📖 Migration guide: ${docUrl}`);
}
console.error(`\nPlease update your environment variables and ${action}.`);
console.error('═'.repeat(70) + '\n');
process.exit(1);
}
/**
* Check for deprecated authentication environment variables and exit if found
* @param {object} options
* @param {string} [options.action='redeploy'] - Action hint in error message ('redeploy' or 'restart')
*/
function checkDeprecatedAuth(options = {}) {
const { action = 'redeploy' } = options;
for (const check of DEPRECATED_CHECKS) {
const foundVars = check.getVars();
if (foundVars.length > 0) {
printErrorAndExit(
check.name,
foundVars,
check.message,
action,
check.docUrl,
check.formatVar,
);
}
}
}
module.exports = { checkDeprecatedAuth };

View file

@ -1,42 +0,0 @@
/**
* Shared utility to check for deprecated Clerk environment variables.
* Used by both prebuild.mts (build time) and startServer.js (Docker runtime).
*
* IMPORTANT: Keep this file as CommonJS (.js) for compatibility with startServer.js
*/
const CLERK_MIGRATION_DOC_URL =
'https://lobehub.com/docs/self-hosting/advanced/auth/clerk-to-betterauth';
const CLERK_ENV_VARS = [
'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
'CLERK_SECRET_KEY',
'CLERK_WEBHOOK_SECRET',
];
/**
* Check for deprecated Clerk environment variables and exit if found
* @param {object} options
* @param {string} [options.action='redeploy'] - Action hint in error message ('redeploy' or 'restart')
*/
function checkDeprecatedClerkEnv(options = {}) {
const { action = 'redeploy' } = options;
const foundClerkEnvVars = CLERK_ENV_VARS.filter((envVar) => process.env[envVar]);
if (foundClerkEnvVars.length > 0) {
console.error('\n' + '═'.repeat(70));
console.error('❌ ERROR: Clerk authentication is no longer supported!');
console.error('═'.repeat(70));
console.error('\nDetected deprecated Clerk environment variables:');
for (const envVar of foundClerkEnvVars) {
console.error(`${envVar}`);
}
console.error('\nClerk has been removed from LobeChat. Please migrate to Better Auth.');
console.error(`\n📖 Migration guide: ${CLERK_MIGRATION_DOC_URL}`);
console.error(`\nAfter migration, remove the Clerk environment variables and ${action}.`);
console.error('═'.repeat(70) + '\n');
process.exit(1);
}
}
module.exports = { checkDeprecatedClerkEnv };

View file

@ -13,6 +13,11 @@ const IS_DRY_RUN =
process.argv.includes('--dry-run') || process.env.CLERK_TO_BETTERAUTH_DRY_RUN === '1';
const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
// ANSI color codes
const GREEN_BOLD = '\u001B[1;32m';
const RED_BOLD = '\u001B[1;31m';
const RESET = '\u001B[0m';
function chunk<T>(items: T[], size: number): T[][] {
if (!Number.isFinite(size) || size <= 0) return [items];
const result: T[][] = [];
@ -241,7 +246,7 @@ async function migrateFromClerk() {
}
console.log(
`[clerk-to-betterauth] completed users=${processed}, skipped=${skipped}, accounts attempted=${accountAttempts}, 2fa attempted=${twoFactorAttempts}, dryRun=${IS_DRY_RUN}, elapsed=${formatDuration(Date.now() - startedAt)}`,
`[clerk-to-betterauth] completed users=${GREEN_BOLD}${processed}${RESET}, skipped=${skipped}, accounts attempted=${accountAttempts}, 2fa attempted=${twoFactorAttempts}, dryRun=${IS_DRY_RUN}, elapsed=${formatDuration(Date.now() - startedAt)}`,
);
const accountCountsText = Object.entries(accountCounts)
@ -301,10 +306,10 @@ async function main() {
try {
await migrateFromClerk();
console.log('');
console.log(`✅ Migration success! (${formatDuration(Date.now() - startedAt)})`);
console.log(`${GREEN_BOLD}✅ Migration success!${RESET} (${formatDuration(Date.now() - startedAt)})`);
} catch (error) {
console.log('');
console.error(`❌ Migration failed (${formatDuration(Date.now() - startedAt)}):`, error);
console.error(`${RED_BOLD}❌ Migration failed${RESET} (${formatDuration(Date.now() - startedAt)}):`, error);
process.exitCode = 1;
} finally {
await pool.end();

View file

@ -0,0 +1,41 @@
import './env';
export type MigrationMode = 'test' | 'prod';
export type DatabaseDriver = 'neon' | 'node';
const DEFAULT_MODE: MigrationMode = 'test';
const DEFAULT_DATABASE_DRIVER: DatabaseDriver = 'neon';
export function getMigrationMode(): MigrationMode {
const mode = process.env.NEXTAUTH_TO_BETTERAUTH_MODE;
if (mode === 'test' || mode === 'prod') return mode;
return DEFAULT_MODE;
}
export function getDatabaseUrl(mode = getMigrationMode()): string {
const key =
mode === 'test'
? 'TEST_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL'
: 'PROD_NEXTAUTH_TO_BETTERAUTH_DATABASE_URL';
const value = process.env[key];
if (!value) {
throw new Error(`${key} is not set`);
}
return value;
}
export function getDatabaseDriver(): DatabaseDriver {
const driver = process.env.NEXTAUTH_TO_BETTERAUTH_DATABASE_DRIVER;
if (driver === 'neon' || driver === 'node') return driver;
return DEFAULT_DATABASE_DRIVER;
}
export function getBatchSize(): number {
return Number(process.env.NEXTAUTH_TO_BETTERAUTH_BATCH_SIZE) || 300;
}
export function isDryRun(): boolean {
return process.argv.includes('--dry-run') || process.env.NEXTAUTH_TO_BETTERAUTH_DRY_RUN === '1';
}

View file

@ -0,0 +1,32 @@
import { Pool as NeonPool, neonConfig } from '@neondatabase/serverless';
import { drizzle as neonDrizzle } from 'drizzle-orm/neon-serverless';
import { drizzle as nodeDrizzle } from 'drizzle-orm/node-postgres';
import { Pool as NodePool } from 'pg';
import ws from 'ws';
// schema is the only dependency on project code, required for type-safe migrations
import * as schemaModule from '../../../packages/database/src/schemas';
import { getDatabaseDriver, getDatabaseUrl } from './config';
function createDatabase() {
const databaseUrl = getDatabaseUrl();
const driver = getDatabaseDriver();
if (driver === 'node') {
const pool = new NodePool({ connectionString: databaseUrl });
const db = nodeDrizzle(pool, { schema: schemaModule });
return { db, pool };
}
// neon driver (default)
// https://github.com/neondatabase/serverless/blob/main/CONFIG.md#websocketconstructor-typeof-websocket--undefined
neonConfig.webSocketConstructor = ws;
const pool = new NeonPool({ connectionString: databaseUrl });
const db = neonDrizzle(pool, { schema: schemaModule });
return { db, pool };
}
const { db, pool } = createDatabase();
export { db, pool };
export * as schema from '../../../packages/database/src/schemas';

View file

@ -0,0 +1,6 @@
import { existsSync } from 'node:fs';
import { loadEnvFile } from 'node:process';
if (existsSync('.env')) {
loadEnvFile();
}

View file

@ -0,0 +1,226 @@
/* eslint-disable unicorn/prefer-top-level-await */
import { sql } from 'drizzle-orm';
import { getBatchSize, getMigrationMode, isDryRun } from './_internal/config';
import { db, pool, schema } from './_internal/db';
const BATCH_SIZE = getBatchSize();
const PROGRESS_TABLE = sql.identifier('nextauth_migration_progress');
const IS_DRY_RUN = isDryRun();
const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
// ANSI color codes
const GREEN_BOLD = '\u001B[1;32m';
const RED_BOLD = '\u001B[1;31m';
const RESET = '\u001B[0m';
function chunk<T>(items: T[], size: number): T[][] {
if (!Number.isFinite(size) || size <= 0) return [items];
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) {
result.push(items.slice(i, i + size));
}
return result;
}
/**
* Convert expires_at (seconds since epoch) to Date
*/
function convertExpiresAt(expiresAt: number | null): Date | undefined {
if (expiresAt === null || expiresAt === undefined) return undefined;
return new Date(expiresAt * 1000);
}
/**
* Convert scope format from NextAuth (space-separated) to Better Auth (comma-separated)
* e.g., "openid profile email" -> "openid,profile,email"
*/
function convertScope(scope: string | null): string | undefined {
if (!scope) return undefined;
return scope.trim().split(/\s+/).join(',');
}
/**
* Create a composite key for nextauth_accounts (provider + providerAccountId)
*/
function createAccountKey(provider: string, providerAccountId: string): string {
return `${provider}__${providerAccountId}`;
}
async function loadNextAuthAccounts() {
const rows = await db.select().from(schema.nextauthAccounts);
return rows;
}
async function migrateFromNextAuth() {
const mode = getMigrationMode();
const nextauthAccounts = await loadNextAuthAccounts();
if (!IS_DRY_RUN) {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS ${PROGRESS_TABLE} (
account_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
`);
}
const processedAccounts = new Set<string>();
if (!IS_DRY_RUN) {
try {
const processedResult = await db.execute<{ account_key: string }>(
sql`SELECT account_key FROM ${PROGRESS_TABLE};`,
);
const rows = (processedResult as { rows?: { account_key: string }[] }).rows ?? [];
for (const row of rows) {
const accountKey = row?.account_key;
if (typeof accountKey === 'string') {
processedAccounts.add(accountKey);
}
}
} catch (error) {
console.warn(
'[nextauth-to-betterauth] failed to read progress table, treating as empty',
error,
);
}
}
console.log(`[nextauth-to-betterauth] mode: ${mode} (dryRun=${IS_DRY_RUN})`);
console.log(`[nextauth-to-betterauth] nextauth accounts: ${nextauthAccounts.length}`);
console.log(`[nextauth-to-betterauth] already processed: ${processedAccounts.size}`);
const unprocessedAccounts = nextauthAccounts.filter(
(acc) => !processedAccounts.has(createAccountKey(acc.provider, acc.providerAccountId)),
);
const batches = chunk(unprocessedAccounts, BATCH_SIZE);
console.log(
`[nextauth-to-betterauth] batches: ${batches.length} (batchSize=${BATCH_SIZE}, toProcess=${unprocessedAccounts.length})`,
);
let processed = 0;
const skipped = nextauthAccounts.length - unprocessedAccounts.length;
const startedAt = Date.now();
const providerCounts: Record<string, number> = {};
const bumpProviderCount = (providerId: string) => {
providerCounts[providerId] = (providerCounts[providerId] ?? 0) + 1;
};
for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
const batch = batches[batchIndex];
const accountRows: (typeof schema.account.$inferInsert)[] = [];
const accountKeys: string[] = [];
for (const nextauthAccount of batch) {
const accountKey = createAccountKey(
nextauthAccount.provider,
nextauthAccount.providerAccountId,
);
const accountRow: typeof schema.account.$inferInsert = {
accessToken: nextauthAccount.access_token ?? undefined,
accessTokenExpiresAt: convertExpiresAt(nextauthAccount.expires_at),
accountId: nextauthAccount.providerAccountId,
// id and createdAt/updatedAt use database defaults
id: accountKey, // deterministic id based on provider + providerAccountId
idToken: nextauthAccount.id_token ?? undefined,
providerId: nextauthAccount.provider,
refreshToken: nextauthAccount.refresh_token ?? undefined,
scope: convertScope(nextauthAccount.scope),
userId: nextauthAccount.userId,
};
accountRows.push(accountRow);
accountKeys.push(accountKey);
bumpProviderCount(nextauthAccount.provider);
}
if (!IS_DRY_RUN) {
await db.transaction(async (tx) => {
if (accountRows.length > 0) {
await tx.insert(schema.account).values(accountRows).onConflictDoNothing();
}
const accountKeyValues = accountKeys.map((key) => sql`(${key})`);
if (accountKeyValues.length > 0) {
await tx.execute(sql`
INSERT INTO ${PROGRESS_TABLE} (account_key)
VALUES ${sql.join(accountKeyValues, sql`, `)}
ON CONFLICT (account_key) DO NOTHING;
`);
}
});
}
processed += batch.length;
console.log(
`[nextauth-to-betterauth] batch ${batchIndex + 1}/${batches.length} done, accounts ${processed}/${unprocessedAccounts.length}, dryRun=${IS_DRY_RUN}`,
);
}
console.log(
`[nextauth-to-betterauth] completed accounts=${GREEN_BOLD}${processed}${RESET}, skipped=${skipped}, dryRun=${IS_DRY_RUN}, elapsed=${formatDuration(Date.now() - startedAt)}`,
);
const providerCountsText = Object.entries(providerCounts)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([providerId, count]) => `${providerId}=${count}`)
.join(', ');
console.log(`[nextauth-to-betterauth] provider counts: ${providerCountsText || 'none recorded'}`);
}
async function main() {
const startedAt = Date.now();
const mode = getMigrationMode();
console.log('');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ NextAuth to Better Auth Migration Script ║');
console.log('╠════════════════════════════════════════════════════════════╣');
console.log(`║ Mode: ${mode.padEnd(48)}`);
console.log(`║ Dry Run: ${(IS_DRY_RUN ? 'YES (no changes will be made)' : 'NO').padEnd(48)}`);
console.log(`║ Batch: ${String(BATCH_SIZE).padEnd(48)}`);
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('');
if (mode === 'prod' && !IS_DRY_RUN) {
console.log('⚠️ WARNING: Running in PRODUCTION mode. Data will be modified!');
console.log(' Type "yes" to continue or press Ctrl+C to abort.');
console.log('');
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => {
rl.question(' Confirm (yes/no): ', (ans) => {
resolve(ans);
});
});
rl.close();
if (answer.toLowerCase() !== 'yes') {
console.log('❌ Aborted by user.');
process.exitCode = 0;
await pool.end();
return;
}
console.log('');
}
try {
await migrateFromNextAuth();
console.log('');
console.log(`${GREEN_BOLD}✅ Migration success!${RESET} (${formatDuration(Date.now() - startedAt)})`);
} catch (error) {
console.log('');
console.error(`${RED_BOLD}❌ Migration failed${RESET} (${formatDuration(Date.now() - startedAt)}):`, error);
process.exitCode = 1;
} finally {
await pool.end();
}
}
void main();

View file

@ -0,0 +1,188 @@
/* eslint-disable unicorn/prefer-top-level-await */
import { getMigrationMode } from './_internal/config';
import { db, pool, schema } from './_internal/db';
type ExpectedAccount = {
accountId: string;
providerId: string;
scope?: string;
userId: string;
};
type ActualAccount = {
accountId: string;
providerId: string;
scope: string | null;
userId: string;
};
const MAX_SAMPLES = 5;
const formatDuration = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
function buildAccountKey(account: { accountId: string; providerId: string; userId: string }) {
return `${account.userId}__${account.providerId}__${account.accountId}`;
}
async function loadNextAuthAccounts() {
const rows = await db.select().from(schema.nextauthAccounts);
return rows;
}
async function loadActualAccounts() {
const rows = await db
.select({
accountId: schema.account.accountId,
providerId: schema.account.providerId,
scope: schema.account.scope,
userId: schema.account.userId,
})
.from(schema.account);
return rows as ActualAccount[];
}
function buildExpectedAccounts(nextauthAccounts: Awaited<ReturnType<typeof loadNextAuthAccounts>>) {
const expectedAccounts: ExpectedAccount[] = [];
for (const nextauthAccount of nextauthAccounts) {
expectedAccounts.push({
accountId: nextauthAccount.providerAccountId,
providerId: nextauthAccount.provider,
scope: nextauthAccount.scope ?? undefined,
userId: nextauthAccount.userId,
});
}
return { expectedAccounts };
}
async function main() {
const startedAt = Date.now();
const mode = getMigrationMode();
console.log('');
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ NextAuth to Better Auth Verification Script ║');
console.log('╠════════════════════════════════════════════════════════════╣');
console.log(`║ Mode: ${mode.padEnd(48)}`);
console.log('╚════════════════════════════════════════════════════════════╝');
console.log('');
const [nextauthAccounts, actualAccounts] = await Promise.all([
loadNextAuthAccounts(),
loadActualAccounts(),
]);
console.log(`📦 [verify] Loaded nextauth_accounts=${nextauthAccounts.length}`);
const { expectedAccounts } = buildExpectedAccounts(nextauthAccounts);
console.log(`🧮 [verify] Expected accounts=${expectedAccounts.length}`);
console.log(`🗄️ [verify] DB snapshot: accounts=${actualAccounts.length}`);
const expectedAccountSet = new Set(expectedAccounts.map(buildAccountKey));
const actualAccountSet = new Set(actualAccounts.map(buildAccountKey));
let missingAccounts = 0;
const missingAccountSamples: string[] = [];
for (const account of expectedAccounts) {
const key = buildAccountKey(account);
if (!actualAccountSet.has(key)) {
missingAccounts += 1;
if (missingAccountSamples.length < MAX_SAMPLES) {
missingAccountSamples.push(`${account.providerId}/${account.accountId}`);
}
}
}
let unexpectedAccounts = 0;
const unexpectedAccountSamples: string[] = [];
for (const account of actualAccounts) {
const key = buildAccountKey(account);
if (!expectedAccountSet.has(key)) {
unexpectedAccounts += 1;
if (unexpectedAccountSamples.length < MAX_SAMPLES) {
unexpectedAccountSamples.push(`${account.providerId}/${account.accountId}`);
}
}
}
// Provider counts
const expectedProviderCounts: Record<string, number> = {};
const actualProviderCounts: Record<string, number> = {};
for (const account of expectedAccounts) {
expectedProviderCounts[account.providerId] =
(expectedProviderCounts[account.providerId] ?? 0) + 1;
}
for (const account of actualAccounts) {
actualProviderCounts[account.providerId] = (actualProviderCounts[account.providerId] ?? 0) + 1;
}
const formatCounts = (counts: Record<string, number>) =>
Object.entries(counts)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([providerId, count]) => `${providerId}=${count}`)
.join(', ');
console.log(
`📊 [verify] Expected provider counts: ${formatCounts(expectedProviderCounts) || 'n/a'}`,
);
console.log(
`📊 [verify] Actual provider counts: ${formatCounts(actualProviderCounts) || 'n/a'}`,
);
// Check for missing scope in actual accounts
let missingScopeNonCredential = 0;
const sampleMissingScope: string[] = [];
for (const account of actualAccounts) {
if (account.providerId !== 'credential' && !account.scope) {
// Find corresponding nextauth account to check if it had scope
const nextauthAccount = nextauthAccounts.find(
(na) => na.provider === account.providerId && na.providerAccountId === account.accountId,
);
if (nextauthAccount?.scope) {
missingScopeNonCredential += 1;
if (sampleMissingScope.length < MAX_SAMPLES) {
sampleMissingScope.push(`${account.providerId}/${account.accountId}`);
}
}
}
}
console.log('');
console.log('📋 [verify] Summary:');
console.log(
` - Missing accounts: ${missingAccounts} ${missingAccountSamples.length > 0 ? `(samples: ${missingAccountSamples.join(', ')})` : ''}`,
);
console.log(
` - Unexpected accounts: ${unexpectedAccounts} ${unexpectedAccountSamples.length > 0 ? `(samples: ${unexpectedAccountSamples.join(', ')})` : '(accounts not from nextauth)'}`,
);
console.log(
` - Missing scope (had in nextauth): ${missingScopeNonCredential} ${sampleMissingScope.length > 0 ? `(samples: ${sampleMissingScope.join(', ')})` : ''}`,
);
console.log('');
if (missingAccounts === 0) {
console.log(
`✅ Verification success! All nextauth accounts migrated. (${formatDuration(Date.now() - startedAt)})`,
);
} else {
console.log(
`⚠️ Verification completed with ${missingAccounts} missing accounts. (${formatDuration(Date.now() - startedAt)})`,
);
}
}
void main()
.catch((error) => {
console.log('');
console.error('❌ Verification failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await pool.end();
});

View file

@ -9,10 +9,11 @@ import { fileURLToPath } from 'node:url';
// Use createRequire for CommonJS module compatibility
const require = createRequire(import.meta.url);
const { checkDeprecatedClerkEnv } = require('./_shared/checkDeprecatedClerkEnv.js');
const { checkDeprecatedAuth } = require('./_shared/checkDeprecatedAuth.js');
const isDesktop = process.env.NEXT_PUBLIC_IS_DESKTOP_APP === '1';
const isBundleAnalyzer = process.env.ANALYZE === 'true' && process.env.CI === 'true';
const isServerDB = !!process.env.DATABASE_URL;
if (isDesktop) {
dotenvExpand.expand(dotenv.config({ path: '.env.desktop' }));
@ -21,10 +22,40 @@ if (isDesktop) {
dotenvExpand.expand(dotenv.config());
}
// Auth flags - use process.env directly for build-time dead code elimination
// Better Auth is the default auth solution when NextAuth is not explicitly enabled
const enableNextAuth = process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1';
const enableBetterAuth = !enableNextAuth;
const AUTH_SECRET_DOC_URL = 'https://lobehub.com/docs/self-hosting/environment-variables/auth#auth-secret';
const KEY_VAULTS_SECRET_DOC_URL = 'https://lobehub.com/docs/self-hosting/environment-variables/basic#key-vaults-secret';
/**
* Check for required environment variables in server database mode
*/
const checkRequiredEnvVars = () => {
if (isDesktop || !isServerDB) return;
const missingVars: { docUrl: string; name: string }[] = [];
if (!process.env.AUTH_SECRET) {
missingVars.push({ docUrl: AUTH_SECRET_DOC_URL, name: 'AUTH_SECRET' });
}
if (!process.env.KEY_VAULTS_SECRET) {
missingVars.push({ docUrl: KEY_VAULTS_SECRET_DOC_URL, name: 'KEY_VAULTS_SECRET' });
}
if (missingVars.length > 0) {
console.error('\n' + '═'.repeat(70));
console.error('❌ ERROR: Missing required environment variables!');
console.error('═'.repeat(70));
console.error('\nThe following environment variables are required for server database mode:\n');
for (const { name, docUrl } of missingVars) {
console.error(`${name}`);
console.error(` 📖 Documentation: ${docUrl}\n`);
}
console.error('Please configure these environment variables and redeploy.');
console.error('═'.repeat(70) + '\n');
process.exit(1);
}
};
const getCommandVersion = (command: string): string | null => {
try {
@ -58,13 +89,32 @@ const printEnvInfo = () => {
console.log(` VERCEL_PROJECT_PRODUCTION_URL: ${process.env.VERCEL_PROJECT_PRODUCTION_URL ?? '(not set)'}`);
console.log(` AUTH_EMAIL_VERIFICATION: ${process.env.AUTH_EMAIL_VERIFICATION ?? '(not set)'}`);
console.log(` ENABLE_MAGIC_LINK: ${process.env.ENABLE_MAGIC_LINK ?? '(not set)'}`);
console.log(` AUTH_SECRET: ${process.env.AUTH_SECRET ? '✓ set' : '✗ not set'}`);
console.log(` KEY_VAULTS_SECRET: ${process.env.KEY_VAULTS_SECRET ? '✓ set' : '✗ not set'}`);
// Auth flags
console.log('\n Auth Flags:');
console.log(` enableBetterAuth: ${enableBetterAuth}`);
console.log(` enableNextAuth: ${enableNextAuth}`);
// Check SSO providers configuration
const ssoProviders = process.env.AUTH_SSO_PROVIDERS;
console.log(` AUTH_SSO_PROVIDERS: ${ssoProviders ?? '(not set)'}`);
if (ssoProviders) {
const getEnvPrefix = (provider: string) => `AUTH_${provider.toUpperCase().replaceAll('-', '_')}`;
const providers = ssoProviders.split(/[,]/).map(p => p.trim()).filter(Boolean);
const missingProviders: string[] = [];
for (const provider of providers) {
const envPrefix = getEnvPrefix(provider);
const hasEnvVar = Object.keys(process.env).some(key => key.startsWith(envPrefix));
if (!hasEnvVar) {
missingProviders.push(provider);
}
}
if (missingProviders.length > 0) {
console.log('\n ⚠️ SSO Provider Configuration Warning:');
for (const provider of missingProviders) {
console.log(` - "${provider}" is configured but no ${getEnvPrefix(provider)}_* env vars found`);
}
}
}
console.log('─'.repeat(50));
};
@ -160,8 +210,11 @@ export const runPrebuild = async (targetDir: string = 'src') => {
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
// Check for deprecated Clerk env vars first - fail fast if found
checkDeprecatedClerkEnv();
// Check for deprecated auth env vars first - fail fast if found
checkDeprecatedAuth();
// Check for required env vars in server database mode
checkRequiredEnvVars();
printEnvInfo();
// 执行删除操作

View file

@ -7,11 +7,11 @@ const { existsSync } = require('node:fs');
// Resolve shared module path for both local dev and Docker environments
// Local: scripts/serverLauncher/startServer.js -> scripts/_shared/...
// Docker: /app/startServer.js -> /app/scripts/_shared/...
const localPath = path.join(__dirname, '..', '_shared', 'checkDeprecatedClerkEnv.js');
const dockerPath = '/app/scripts/_shared/checkDeprecatedClerkEnv.js';
const localPath = path.join(__dirname, '..', '_shared', 'checkDeprecatedAuth.js');
const dockerPath = '/app/scripts/_shared/checkDeprecatedAuth.js';
const sharedModulePath = existsSync(localPath) ? localPath : dockerPath;
const { checkDeprecatedClerkEnv } = require(sharedModulePath);
const { checkDeprecatedAuth } = require(sharedModulePath);
// Set file paths
const DB_MIGRATION_SCRIPT_PATH = '/app/docker.cjs';
@ -139,8 +139,8 @@ const runServer = async () => {
// Main execution block
(async () => {
// Check for deprecated Clerk env vars first - fail fast if found
checkDeprecatedClerkEnv({ action: 'restart' });
// Check for deprecated auth env vars first - fail fast if found
checkDeprecatedAuth({ action: 'restart' });
console.log('🌐 DNS Server:', dns.getServers());
console.log('-------------------------------------');

View file

@ -1,32 +1,14 @@
import { toNextJsHandler } from 'better-auth/next-js';
import type { NextRequest } from 'next/server';
import { enableBetterAuth, enableNextAuth } from '@/envs/auth';
import { auth } from '@/auth';
const createHandler = async () => {
if (enableBetterAuth) {
const [{ toNextJsHandler }, { auth }] = await Promise.all([
import('better-auth/next-js'),
import('@/auth'),
]);
return toNextJsHandler(auth);
}
if (enableNextAuth) {
const NextAuthNode = await import('@/libs/next-auth');
return NextAuthNode.default.handlers;
}
return { GET: undefined, POST: undefined };
};
const handler = createHandler();
const handler = toNextJsHandler(auth);
export const GET = async (req: NextRequest) => {
const { GET } = await handler;
return GET?.(req);
return handler.GET(req);
};
export const POST = async (req: NextRequest) => {
const { POST } = await handler;
return POST?.(req);
return handler.POST(req);
};

View file

@ -1,137 +0,0 @@
import debug from 'debug';
import { type NextRequest, NextResponse } from 'next/server';
import { serverDBEnv } from '@/config/db';
import { serverDB } from '@/database/server';
import { dateKeys } from '@/libs/next-auth/adapter';
import { NextAuthUserService } from '@/server/services/nextAuthUser';
const log = debug('lobe-next-auth:api:auth:adapter');
/**
* @description Process the db query for the NextAuth adapter.
* Returns the db query result directly and let NextAuth handle the raw results.
* @returns {
* success: boolean; // Only return false if the database query fails or the action is invalid.
* data?: any;
* error?: string;
* }
*/
export async function POST(req: NextRequest) {
try {
// try validate the request
if (
!req.headers.get('Authorization') ||
req.headers.get('Authorization')?.trim() !== `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`
) {
log('Unauthorized request, missing or invalid Authorization header');
return NextResponse.json({ error: 'Unauthorized', success: false }, { status: 401 });
}
// Parse the request body
const data = await req.json();
log('Received request data:', data);
// Preprocess
if (data?.data) {
for (const key of dateKeys) {
if (data?.data && data.data[key]) {
data.data[key] = new Date(data.data[key]);
continue;
}
}
}
const service = new NextAuthUserService(serverDB);
let result;
switch (data.action) {
case 'createAuthenticator': {
result = await service.createAuthenticator(data.data);
break;
}
case 'createSession': {
result = await service.createSession(data.data);
break;
}
case 'createUser': {
result = await service.createUser(data.data);
break;
}
case 'createVerificationToken': {
result = await service.createVerificationToken(data.data);
break;
}
case 'deleteSession': {
result = await service.deleteSession(data.data);
break;
}
case 'deleteUser': {
result = await service.deleteUser(data.data);
break;
}
case 'getAccount': {
result = await service.getAccount(data.data.providerAccountId, data.data.provider);
break;
}
case 'getAuthenticator': {
result = await service.getAuthenticator(data.data);
break;
}
case 'getSessionAndUser': {
result = await service.getSessionAndUser(data.data);
break;
}
case 'getUser': {
result = await service.getUser(data.data);
break;
}
case 'getUserByAccount': {
result = await service.getUserByAccount(data.data);
break;
}
case 'getUserByEmail': {
result = await service.getUserByEmail(data.data);
break;
}
case 'linkAccount': {
result = await service.linkAccount(data.data);
break;
}
case 'listAuthenticatorsByUserId': {
result = await service.listAuthenticatorsByUserId(data.data);
break;
}
case 'unlinkAccount': {
result = await service.unlinkAccount(data.data);
break;
}
case 'updateAuthenticatorCounter': {
result = await service.updateAuthenticatorCounter(
data.data.credentialID,
data.data.counter,
);
break;
}
case 'updateSession': {
result = await service.updateSession(data.data);
break;
}
case 'updateUser': {
result = await service.updateUser(data.data);
break;
}
case 'useVerificationToken': {
result = await service.useVerificationToken(data.data);
break;
}
default: {
return NextResponse.json({ error: 'Invalid action', success: false }, { status: 400 });
}
}
return NextResponse.json({ data: result, success: true });
} catch (error) {
log('Error processing request:');
log(error);
return NextResponse.json({ error, success: false }, { status: 400 });
}
}
export const runtime = 'nodejs';

View file

@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
import { serverDB } from '@/database/server';
import { authEnv } from '@/envs/auth';
import { pino } from '@/libs/logger';
import { NextAuthUserService } from '@/server/services/nextAuthUser';
import { WebhookUserService } from '@/server/services/webhookUser';
import { validateRequest } from './validateRequest';
@ -19,13 +19,13 @@ export const POST = async (req: Request): Promise<NextResponse> => {
const { action, object } = payload;
const nextAuthUserService = new NextAuthUserService(serverDB);
const webhookUserService = new WebhookUserService(serverDB);
switch (action) {
case 'update-user': {
return nextAuthUserService.safeUpdateUser(
return webhookUserService.safeUpdateUser(
{
provider: 'casdoor',
providerAccountId: object.id,
accountId: object.id,
providerId: 'casdoor',
},
{
avatar: object?.avatar,

View file

@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
import { serverDB } from '@/database/server';
import { authEnv } from '@/envs/auth';
import { pino } from '@/libs/logger';
import { NextAuthUserService } from '@/server/services/nextAuthUser';
import { WebhookUserService } from '@/server/services/webhookUser';
import { validateRequest } from './validateRequest';
@ -21,13 +21,13 @@ export const POST = async (req: Request): Promise<NextResponse> => {
pino.trace(`logto webhook payload: ${{ data, event }}`);
const nextAuthUserService = new NextAuthUserService(serverDB);
const webhookUserService = new WebhookUserService(serverDB);
switch (event) {
case 'User.Data.Updated': {
return nextAuthUserService.safeUpdateUser(
return webhookUserService.safeUpdateUser(
{
provider: 'logto',
providerAccountId: data.id,
accountId: data.id,
providerId: 'logto',
},
{
avatar: data?.avatar,
@ -38,9 +38,9 @@ export const POST = async (req: Request): Promise<NextResponse> => {
}
case 'User.SuspensionStatus.Updated': {
if (data.isSuspended) {
return nextAuthUserService.safeSignOutUser({
provider: 'logto',
providerAccountId: data.id,
return webhookUserService.safeSignOutUser({
accountId: data.id,
providerId: 'logto',
});
}
return NextResponse.json({ message: 'user reactivated', success: true }, { status: 200 });

View file

@ -24,10 +24,17 @@ vi.mock('@/envs/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/envs/auth')>();
return {
...actual,
enableBetterAuth: false,
};
});
vi.mock('@/auth', () => ({
auth: {
api: {
getSession: vi.fn().mockResolvedValue(null),
},
},
}));
describe('checkAuth', () => {
const mockHandler: RequestHandler = vi.fn();
const mockRequest = new Request('https://example.com');

View file

@ -6,14 +6,10 @@ import {
import { ChatErrorType, type ClientSecretPayload } from '@lobechat/types';
import { getXorPayload } from '@lobechat/utils/server';
import { auth } from '@/auth';
import { getServerDB } from '@/database/core/db-adaptor';
import { type LobeChatDatabase } from '@/database/type';
import {
LOBE_CHAT_AUTH_HEADER,
LOBE_CHAT_OIDC_AUTH_HEADER,
OAUTH_AUTHORIZED,
enableBetterAuth,
} from '@/envs/auth';
import { LOBE_CHAT_AUTH_HEADER, LOBE_CHAT_OIDC_AUTH_HEADER, OAUTH_AUTHORIZED } from '@/envs/auth';
import { validateOIDCJWT } from '@/libs/oidc-provider/jwt';
import { createErrorResponse } from '@/utils/errorResponse';
@ -58,18 +54,13 @@ export const checkAuth =
// get Authorization from header
const authorization = req.headers.get(LOBE_CHAT_AUTH_HEADER);
const oauthAuthorized = !!req.headers.get(OAUTH_AUTHORIZED);
let betterAuthAuthorized = false;
// better auth handler
if (enableBetterAuth) {
const { auth: betterAuth } = await import('@/auth');
const session = await auth.api.getSession({
headers: req.headers,
});
const session = await betterAuth.api.getSession({
headers: req.headers,
});
betterAuthAuthorized = !!session?.user?.id;
}
const betterAuthAuthorized = !!session?.user?.id;
if (!authorization) throw AgentRuntimeError.createError(ChatErrorType.Unauthorized);

View file

@ -2,49 +2,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { checkAuthMethod } from './utils';
let enableNextAuthMock = false;
let enableBetterAuthMock = false;
vi.mock('@/envs/auth', async (importOriginal) => {
const data = await importOriginal();
return {
...(data as any),
get enableBetterAuth() {
return enableBetterAuthMock;
},
get enableNextAuth() {
return enableNextAuthMock;
},
};
});
describe('checkAuthMethod', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should pass with valid Next auth', () => {
enableNextAuthMock = true;
expect(() =>
checkAuthMethod({
nextAuthAuthorized: true,
}),
).not.toThrow();
enableNextAuthMock = false;
});
it('should pass with valid Better Auth session', () => {
enableBetterAuthMock = true;
expect(() =>
checkAuthMethod({
betterAuthAuthorized: true,
}),
).not.toThrow();
enableBetterAuthMock = false;
});
it('should pass with valid API key', () => {

View file

@ -1,5 +1,3 @@
import { enableBetterAuth, enableNextAuth } from '@/envs/auth';
interface CheckAuthParams {
apiKey?: string;
betterAuthAuthorized?: boolean;
@ -11,16 +9,13 @@ interface CheckAuthParams {
* @param {CheckAuthParams} params - Authentication parameters extracted from headers.
* @param {string} [params.apiKey] - The user API key.
* @param {boolean} [params.betterAuthAuthorized] - Whether the Better Auth session exists.
* @param {boolean} [params.nextAuthAuthorized] - Whether the OAuth 2 header is provided.
* @param {boolean} [params.nextAuthAuthorized] - Whether the OAuth 2 header is provided (legacy, kept for compatibility).
*/
export const checkAuthMethod = (params: CheckAuthParams) => {
const { apiKey, betterAuthAuthorized, nextAuthAuthorized } = params;
const { apiKey, betterAuthAuthorized } = params;
// if better auth session exists
if (enableBetterAuth && betterAuthAuthorized) return;
// if next auth handler is provided
if (enableNextAuth && nextAuthAuthorized) return;
if (betterAuthAuthorized) return;
// if apiKey exist
if (apiKey) return;

View file

@ -27,10 +27,17 @@ vi.mock('@/envs/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/envs/auth')>();
return {
...actual,
enableBetterAuth: false,
};
});
vi.mock('@/auth', () => ({
auth: {
api: {
getSession: vi.fn().mockResolvedValue(null),
},
},
}));
// 模拟请求和响应
let request: Request;
beforeEach(() => {

View file

@ -21,7 +21,6 @@ const handler = async (req: Request, { jwtPayload }: { jwtPayload?: any }) => {
const caller = createCaller({
jwtPayload,
nextAuth: undefined, // WebAPI routes don't have nextAuth session
userId: jwtPayload?.userId, // Required for userAuth middleware
});

View file

@ -21,10 +21,17 @@ vi.mock('@/envs/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/envs/auth')>();
return {
...actual,
enableBetterAuth: false,
};
});
vi.mock('@/auth', () => ({
auth: {
api: {
getSession: vi.fn().mockResolvedValue(null),
},
},
}));
vi.mock('@/server/modules/ModelRuntime', () => ({
initModelRuntimeFromDB: vi.fn(),
}));

View file

@ -1,40 +0,0 @@
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from '@/libs/next/navigation';
import { memo } from 'react';
import ErrorCapture from '@/components/Error';
enum ErrorEnum {
AccessDenied = 'AccessDenied',
Configuration = 'Configuration',
Default = 'Default',
Verification = 'Verification',
}
const errorMap = {
[ErrorEnum.Configuration]:
'Wrong configuration, make sure you have the correct environment variables set. Visit https://lobehub.com/docs/self-hosting/advanced/authentication for more details.',
[ErrorEnum.AccessDenied]:
'Access was denied. Visit https://authjs.dev/reference/core/errors#accessdenied for more details. ',
[ErrorEnum.Verification]:
'Verification error, visit https://authjs.dev/reference/core/errors#verification for more details.',
[ErrorEnum.Default]:
'There was a problem when trying to authenticate. Visit https://authjs.dev/reference/core/errors for more details.',
};
export default memo(() => {
const search = useSearchParams();
const error = search.get('error') as ErrorEnum;
const props = {
error: {
cause: error,
message: errorMap[error] || 'Unknown error type.',
name: 'NextAuth Error',
},
reset: () => signIn(undefined, { callbackUrl: '/' }),
};
console.log('[NextAuth] Error:', props.error);
return <ErrorCapture {...props} />;
});

View file

@ -1,11 +0,0 @@
import { Suspense } from 'react';
import Loading from '@/components/Loading/BrandTextLoading';
import AuthErrorPage from './AuthErrorPage';
export default () => (
<Suspense fallback={<Loading debugId="Auth > Error" />}>
<AuthErrorPage />
</Suspense>
);

View file

@ -1,167 +0,0 @@
'use client';
import { BRANDING_NAME } from '@lobechat/business-const';
import { DOCUMENTS_REFER_URL, PRIVACY_URL, TERMS_URL } from '@lobechat/const';
import { Button, Skeleton, Text } from '@lobehub/ui';
import { LobeHub } from '@lobehub/ui/brand';
import { Col, Flex, Row } from 'antd';
import { createStaticStyles } from 'antd-style';
import { AuthError } from 'next-auth';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from '@/libs/next/navigation';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import BrandWatermark from '@/components/BrandWatermark';
import AuthIcons from '@/components/NextAuth/AuthIcons';
import { useUserStore } from '@/store/user';
const styles = createStaticStyles(({ css, cssVar }) => ({
button: css`
text-transform: capitalize;
`,
container: css`
min-width: 360px;
border: 1px solid ${cssVar.colorBorder};
border-radius: ${cssVar.borderRadiusLG}px;
background: ${cssVar.colorBgContainer};
`,
contentCard: css`
padding-block: 2.5rem;
padding-inline: 2rem;
`,
description: css`
margin: 0;
color: ${cssVar.colorTextSecondary};
`,
footer: css`
padding: 1rem;
border-block-start: 1px solid ${cssVar.colorBorder};
border-radius: 0 0 8px 8px;
color: ${cssVar.colorTextDescription};
background: ${cssVar.colorBgElevated};
`,
text: css`
text-align: center;
`,
title: css`
margin: 0;
color: ${cssVar.colorTextHeading};
`,
}));
const BtnListLoading = memo(() => {
return (
<Flex gap={'small'} vertical>
<Skeleton.Button active style={{ minWidth: 300 }} />
<Skeleton.Button active style={{ minWidth: 300 }} />
<Skeleton.Button active style={{ minWidth: 300 }} />
</Flex>
);
});
/**
* Follow the implementation from AuthJS official documentation,
* but using client components.
* ref: https://authjs.dev/guides/pages/signin
*/
export default memo(() => {
const { t } = useTranslation('auth');
const { t: tCommon } = useTranslation('common');
const router = useRouter();
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const oAuthSSOProviders = useUserStore((s) => s.oAuthSSOProviders);
const searchParams = useSearchParams();
// Redirect back to the page url, fallback to '/' if failed
const callbackUrl = searchParams.get('callbackUrl') ?? '/';
const handleSignIn = async (provider: string) => {
setLoadingProvider(provider);
try {
await signIn(provider, { redirectTo: callbackUrl });
} catch (error) {
setLoadingProvider(null);
// Signin can fail for a number of reasons, such as the user
// not existing, or the user not having the correct role.
// In some cases, you may want to redirect to a custom error
if (error instanceof AuthError) {
return router.push(`/next-auth/?error=${error.type}`);
}
// Otherwise if a redirects happens Next.js can handle it
// so you can just re-thrown the error and let Next.js handle it.
// Docs: https://nextjs.org/docs/app/api-reference/functions/redirect#server-component
throw error;
}
};
const footerBtns = [
{ href: DOCUMENTS_REFER_URL, id: 0, label: tCommon('document') },
{ href: PRIVACY_URL, id: 1, label: t('footer.privacy') },
{ href: TERMS_URL, id: 2, label: t('footer.terms') },
];
return (
<div className={styles.container}>
<div className={styles.contentCard}>
{/* Card Body */}
<Flex gap="large" vertical>
{/* Header */}
<div className={styles.text}>
<Text as={'h4'} className={styles.title}>
<div>
<LobeHub size={48} />
</div>
{t('signin.title')}
</Text>
<Text as={'p'} className={styles.description}>
{t('signin.subtitle', { appName: BRANDING_NAME })}
</Text>
</div>
{/* Content */}
<Flex gap="small" vertical>
{oAuthSSOProviders ? (
oAuthSSOProviders.map((provider) => (
<Button
className={styles.button}
icon={AuthIcons(provider, 16)}
key={provider}
loading={loadingProvider === provider}
onClick={() => handleSignIn(provider)}
>
{provider}
</Button>
))
) : (
<BtnListLoading />
)}
</Flex>
</Flex>
</div>
<div className={styles.footer}>
{/* Footer */}
<Row>
<Col span={12}>
<Flex justify="left" style={{ height: '100%' }}>
<BrandWatermark />
</Flex>
</Col>
<Col offset={4} span={8}>
<Flex justify="right">
{footerBtns.map((btn) => (
<Button key={btn.id} onClick={() => router.push(btn.href)} size="small" type="text">
{btn.label}
</Button>
))}
</Flex>
</Col>
</Row>
</div>
</div>
);
});

View file

@ -1,11 +0,0 @@
import { Suspense } from 'react';
import Loading from '@/components/Loading/BrandTextLoading';
import AuthSignInBox from './AuthSignInBox';
export default () => (
<Suspense fallback={<Loading debugId="Auth > SignIn" />}>
<AuthSignInBox />
</Suspense>
);

View file

@ -1,12 +0,0 @@
import { notFound } from '@/libs/next/navigation';
import { type PropsWithChildren } from 'react';
import { enableBetterAuth } from '@/envs/auth';
const Layout = ({ children }: PropsWithChildren) => {
if (!enableBetterAuth) return notFound();
return children;
};
export default Layout;

View file

@ -7,7 +7,7 @@ import { ChevronRight, Mail } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import AuthIcons from '@/components/NextAuth/AuthIcons';
import AuthIcons from '@/components/AuthIcons';
import { PRIVACY_URL, TERMS_URL } from '@/const/url';
import AuthCard from '../../../../features/AuthCard';

View file

@ -1,12 +0,0 @@
import { notFound } from '@/libs/next/navigation';
import { type PropsWithChildren } from 'react';
import { enableBetterAuth } from '@/envs/auth';
const Layout = ({ children }: PropsWithChildren) => {
if (!enableBetterAuth) return notFound();
return children;
};
export default Layout;

View file

@ -1,5 +1,3 @@
import { enableBetterAuth } from '@/envs/auth';
import { notFound } from '@/libs/next/navigation';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { type DynamicLayoutProps } from '@/types/next';
@ -9,28 +7,17 @@ import BetterAuthSignUpForm from './BetterAuthSignUpForm';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const locale = await RouteVariants.getLocale(props);
if (enableBetterAuth) {
const { t } = await translation('auth', locale);
return metadataModule.generate({
description: t('betterAuth.signup.subtitle'),
title: t('betterAuth.signup.title'),
url: '/signup',
});
}
const { t } = await translation('auth', locale);
return metadataModule.generate({
title: 'Sign Up',
description: t('betterAuth.signup.subtitle'),
title: t('betterAuth.signup.title'),
url: '/signup',
});
};
const Page = () => {
if (enableBetterAuth) {
return <BetterAuthSignUpForm />;
}
return notFound();
return <BetterAuthSignUpForm />;
};
export default Page;

View file

@ -1,12 +0,0 @@
import { notFound } from '@/libs/next/navigation';
import { type PropsWithChildren } from 'react';
import { enableBetterAuth } from '@/envs/auth';
const Layout = ({ children }: PropsWithChildren) => {
if (!enableBetterAuth) return notFound();
return children;
};
export default Layout;

View file

@ -5,8 +5,7 @@ import { type CSSProperties, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { modal, notification } from '@/components/AntdStaticMethods';
import AuthIcons from '@/components/NextAuth/AuthIcons';
import { userService } from '@/services/user';
import AuthIcons from '@/components/AuthIcons';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
import { useUserStore } from '@/store/user';
@ -17,7 +16,7 @@ const providerNameStyle: CSSProperties = {
};
export const SSOProvidersList = memo(() => {
const isLoginWithBetterAuth = useUserStore(authSelectors.isLoginWithBetterAuth);
const isLogin = useUserStore(authSelectors.isLogin);
const providers = useUserStore(authSelectors.authProviders);
const hasPasswordAccount = useUserStore(authSelectors.hasPasswordAccount);
const refreshAuthProviders = useUserStore((s) => s.refreshAuthProviders);
@ -26,7 +25,7 @@ export const SSOProvidersList = memo(() => {
// Allow unlink if user has multiple SSO providers OR has email/password login
const allowUnlink = providers.length > 1 || hasPasswordAccount;
const enableBetterAuthActions = !isDesktop && isLoginWithBetterAuth;
const enableAuthActions = !isDesktop && isLogin;
// Get linked provider IDs for filtering
const linkedProviderIds = useMemo(() => {
@ -38,9 +37,9 @@ export const SSOProvidersList = memo(() => {
return (oAuthSSOProviders || []).filter((provider) => !linkedProviderIds.has(provider));
}, [oAuthSSOProviders, linkedProviderIds]);
const handleUnlinkSSO = async (provider: string, providerAccountId: string) => {
const handleUnlinkSSO = async (provider: string) => {
// Better-auth link/unlink operations are not available on desktop
if (isDesktop && isLoginWithBetterAuth) return;
if (isDesktop) return;
// Prevent unlink if this is the only login method
if (!allowUnlink) {
@ -55,14 +54,8 @@ export const SSOProvidersList = memo(() => {
danger: true,
},
onOk: async () => {
if (isLoginWithBetterAuth) {
// Use better-auth native API
const { unlinkAccount } = await import('@/libs/better-auth/auth-client');
await unlinkAccount({ providerId: provider });
} else {
// Fallback for NextAuth
await userService.unlinkSSOProvider(provider, providerAccountId);
}
const { unlinkAccount } = await import('@/libs/better-auth/auth-client');
await unlinkAccount({ providerId: provider });
refreshAuthProviders();
},
title: <span style={providerNameStyle}>{t('profile.sso.unlink.title', { provider })}</span>,
@ -70,7 +63,7 @@ export const SSOProvidersList = memo(() => {
};
const handleLinkSSO = async (provider: string) => {
if (enableBetterAuthActions) {
if (enableAuthActions) {
// Use better-auth native linkSocial API
const { linkSocial } = await import('@/libs/better-auth/auth-client');
await linkSocial({
@ -107,19 +100,19 @@ export const SSOProvidersList = memo(() => {
</Text>
)}
</Flexbox>
{!(isDesktop && isLoginWithBetterAuth) && (
{!isDesktop && (
<ActionIcon
disabled={!allowUnlink}
icon={Unlink}
onClick={() => handleUnlinkSSO(item.provider, item.providerAccountId)}
onClick={() => handleUnlinkSSO(item.provider)}
size={'small'}
/>
)}
</Flexbox>
))}
{/* Link Account Button - Only show for Better-Auth users with available providers */}
{enableBetterAuthActions && availableProviders.length > 0 && (
{/* Link Account Button - Only show for logged in users with available providers */}
{enableAuthActions && availableProviders.length > 0 && (
<DropdownMenu items={linkMenuItems} popupProps={{ style: { maxWidth: '200px' } }}>
<Flexbox align={'center'} gap={6} horizontal style={{ cursor: 'pointer', fontSize: 12 }}>
<Plus size={14} />

View file

@ -51,10 +51,7 @@ interface ProfileSettingProps {
}
const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
const [isLoginWithNextAuth, isLoginWithBetterAuth] = useUserStore((s) => [
authSelectors.isLoginWithNextAuth(s),
authSelectors.isLoginWithBetterAuth(s),
]);
const isLogin = useUserStore(authSelectors.isLogin);
const [userProfile, isUserLoaded] = useUserStore((s) => [
userProfileSelectors.userProfile(s),
s.isLoaded,
@ -72,17 +69,14 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
// Fetch Klavis servers
useFetchUserKlavisServers(enableKlavis);
const isLoginWithAuth = isLoginWithNextAuth || isLoginWithBetterAuth;
const isLoading =
!isUserLoaded ||
(isLoginWithAuth && !isLoadedAuthProviders) ||
(enableKlavis && !isServersInit);
!isUserLoaded || (isLogin && !isLoadedAuthProviders) || (enableKlavis && !isServersInit);
useEffect(() => {
if (isLoginWithAuth) {
if (isLogin) {
fetchAuthProviders();
}
}, [isLoginWithAuth, fetchAuthProviders]);
}, [isLogin, fetchAuthProviders]);
const { t } = useTranslation('auth');
@ -118,8 +112,8 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
{/* Interests Row - Editable */}
<InterestsRow mobile={mobile} />
{/* Password Row - For Better Auth users to change or set password */}
{!isDesktop && isLoginWithBetterAuth && (
{/* Password Row - For logged in users to change or set password */}
{!isDesktop && isLogin && (
<>
<Divider style={{ margin: 0 }} />
<PasswordRow mobile={mobile} />
@ -127,7 +121,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
)}
{/* Email Row - Read Only */}
{isLoginWithAuth && userProfile?.email && (
{isLogin && userProfile?.email && (
<>
<Divider style={{ margin: 0 }} />
<ProfileRow label={t('profile.email')} mobile={mobile}>
@ -137,7 +131,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
)}
{/* SSO Providers Row */}
{isLoginWithAuth && (
{isLogin && (
<>
<Divider style={{ margin: 0 }} />
<ProfileRow label={t('profile.sso.providers')} mobile={mobile}>

View file

@ -8,12 +8,10 @@ import {
Github,
Logto,
MicrosoftEntra,
NextAuth,
Zitadel,
} from '@lobehub/ui/icons';
import React from 'react';
import { User } from 'lucide-react';
// TODO: check this
const iconComponents: { [key: string]: any } = {
'apple': Apple,
'auth0': Auth0,
@ -22,7 +20,6 @@ const iconComponents: { [key: string]: any } = {
'casdoor': Casdoor.Color,
'cloudflare': Cloudflare.Color,
'cognito': Aws.Color,
'default': NextAuth.Color,
'github': Github,
'google': Google.Color,
'logto': Logto.Color,
@ -32,14 +29,15 @@ const iconComponents: { [key: string]: any } = {
};
/**
* Get the auth icons component for the given id
* @param id
* @param size default is 36
* @returns
* Get the auth icons component for the given provider id
*/
const AuthIcons = (id: string, size = 36) => {
const IconComponent = iconComponents[id] || iconComponents.default;
return <IconComponent size={size} />;
const IconComponent = iconComponents[id];
if (IconComponent) {
return <IconComponent size={size} />;
}
// Fallback to generic user icon for unknown providers
return <User size={size} />;
};
export default AuthIcons;

View file

@ -1,47 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getAuthConfig } from './auth';
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_WINDOW = globalThis.window;
describe('getAuthConfig fallbacks', () => {
beforeEach(() => {
// reset env to a clean clone before each test
process.env = { ...ORIGINAL_ENV };
globalThis.window = ORIGINAL_WINDOW;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
globalThis.window = ORIGINAL_WINDOW;
});
it('should fall back to NEXT_AUTH_SSO_PROVIDERS when AUTH_SSO_PROVIDERS is empty string', () => {
process.env.AUTH_SSO_PROVIDERS = '';
process.env.NEXT_AUTH_SSO_PROVIDERS = 'logto,github';
// Simulate server runtime so @t3-oss/env treats this as server-side access
// (happy-dom sets window by default in Vitest)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - allow overriding for test
globalThis.window = undefined;
const config = getAuthConfig();
expect(config.AUTH_SSO_PROVIDERS).toBe('logto,github');
});
it('should fall back to NEXT_AUTH_SECRET when AUTH_SECRET is empty string', () => {
process.env.AUTH_SECRET = '';
process.env.NEXT_AUTH_SECRET = 'nextauth-secret';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - allow overriding for test
globalThis.window = undefined;
const config = getAuthConfig();
expect(config.AUTH_SECRET).toBe('nextauth-secret');
});
});

View file

@ -6,23 +6,15 @@ declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {
// ===== Auth (shared by Better Auth / Next Auth) ===== //
// ===== Better Auth ===== //
AUTH_SECRET?: string;
AUTH_EMAIL_VERIFICATION?: string;
ENABLE_MAGIC_LINK?: string;
AUTH_SSO_PROVIDERS?: string;
AUTH_TRUSTED_ORIGINS?: string;
AUTH_ALLOWED_EMAILS?: string;
// ===== Next Auth ===== //
NEXT_AUTH_SECRET?: string;
NEXT_AUTH_SSO_PROVIDERS?: string;
NEXT_AUTH_DEBUG?: string;
NEXT_AUTH_SSO_SESSION_STRATEGY?: string;
// ===== Next Auth Provider Credentials ===== //
// ===== Auth Provider Credentials ===== //
AUTH_GOOGLE_ID?: string;
AUTH_GOOGLE_SECRET?: string;
@ -130,26 +122,14 @@ declare global {
export const getAuthConfig = () => {
return createEnv({
client: {
// ---------------------------------- better auth ----------------------------------
NEXT_PUBLIC_ENABLE_BETTER_AUTH: z.boolean().optional(),
// ---------------------------------- next auth ----------------------------------
NEXT_PUBLIC_ENABLE_NEXT_AUTH: z.boolean().optional(),
},
client: {},
server: {
// ---------------------------------- better auth ----------------------------------
AUTH_SECRET: z.string().optional(),
AUTH_SSO_PROVIDERS: z.string().optional().default(''),
AUTH_TRUSTED_ORIGINS: z.string().optional(),
AUTH_EMAIL_VERIFICATION: z.boolean().optional().default(false),
ENABLE_MAGIC_LINK: z.boolean().optional().default(false),
// ---------------------------------- next auth ----------------------------------
NEXT_AUTH_SECRET: z.string().optional(),
NEXT_AUTH_SSO_PROVIDERS: z.string().optional().default('auth0'),
NEXT_AUTH_DEBUG: z.boolean().optional().default(false),
NEXT_AUTH_SSO_SESSION_STRATEGY: z.enum(['jwt', 'database']).optional().default('jwt'),
AUTH_ALLOWED_EMAILS: z.string().optional(),
AUTH_GOOGLE_ID: z.string().optional(),
AUTH_GOOGLE_SECRET: z.string().optional(),
@ -248,33 +228,19 @@ export const getAuthConfig = () => {
},
runtimeEnv: {
// ---------------------------------- better auth ----------------------------------
NEXT_PUBLIC_ENABLE_BETTER_AUTH: process.env.NEXT_PUBLIC_ENABLE_BETTER_AUTH === '1',
// Fallback to NEXT_PUBLIC_* for seamless migration
AUTH_EMAIL_VERIFICATION:
process.env.AUTH_EMAIL_VERIFICATION === '1' ||
process.env.NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION === '1',
ENABLE_MAGIC_LINK:
process.env.ENABLE_MAGIC_LINK === '1' || process.env.NEXT_PUBLIC_ENABLE_MAGIC_LINK === '1',
// Fallback to NEXT_AUTH_SECRET for seamless migration from next-auth
AUTH_SECRET: process.env.AUTH_SECRET || process.env.NEXT_AUTH_SECRET,
// Fallback to NEXT_AUTH_SSO_PROVIDERS for seamless migration from next-auth
AUTH_SSO_PROVIDERS: process.env.AUTH_SSO_PROVIDERS || process.env.NEXT_AUTH_SSO_PROVIDERS,
AUTH_EMAIL_VERIFICATION: process.env.AUTH_EMAIL_VERIFICATION === '1',
ENABLE_MAGIC_LINK: process.env.ENABLE_MAGIC_LINK === '1',
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_SSO_PROVIDERS: process.env.AUTH_SSO_PROVIDERS,
AUTH_TRUSTED_ORIGINS: process.env.AUTH_TRUSTED_ORIGINS,
AUTH_ALLOWED_EMAILS: process.env.AUTH_ALLOWED_EMAILS,
// better-auth env for Cognito provider is different from next-auth's one
// Cognito provider specific env vars
AUTH_COGNITO_DOMAIN: process.env.AUTH_COGNITO_DOMAIN,
AUTH_COGNITO_REGION: process.env.AUTH_COGNITO_REGION,
AUTH_COGNITO_USERPOOL_ID: process.env.AUTH_COGNITO_USERPOOL_ID,
// ---------------------------------- next auth ----------------------------------
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,
NEXT_AUTH_SSO_SESSION_STRATEGY: process.env.NEXT_AUTH_SSO_SESSION_STRATEGY || 'jwt',
// Next Auth Provider Credentials
// Auth Provider Credentials
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
@ -374,11 +340,6 @@ export const getAuthConfig = () => {
export const authEnv = getAuthConfig();
// Auth flags - use process.env directly for build-time dead code elimination
// Better Auth is the default auth solution when NextAuth is not explicitly enabled
export const enableNextAuth = process.env.NEXT_PUBLIC_ENABLE_NEXT_AUTH === '1';
export const enableBetterAuth = !enableNextAuth;
// Auth headers and constants
export const LOBE_CHAT_AUTH_HEADER = 'X-lobe-chat-auth';
export const LOBE_CHAT_OIDC_AUTH_HEADER = 'Oidc-Auth';

View file

@ -9,6 +9,7 @@ declare global {
EMAIL_SERVICE_PROVIDER?: string;
RESEND_API_KEY?: string;
RESEND_FROM?: string;
SMTP_FROM?: string;
SMTP_HOST?: string;
SMTP_PASS?: string;
SMTP_PORT?: string;
@ -24,6 +25,7 @@ export const getEmailConfig = () => {
EMAIL_SERVICE_PROVIDER: z.enum(['nodemailer', 'resend']).optional(),
RESEND_API_KEY: z.string().optional(),
RESEND_FROM: z.string().optional(),
SMTP_FROM: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_SECURE: z.boolean().optional(),
@ -31,6 +33,7 @@ export const getEmailConfig = () => {
SMTP_PASS: z.string().optional(),
},
runtimeEnv: {
SMTP_FROM: process.env.SMTP_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined,
SMTP_SECURE: process.env.SMTP_SECURE === 'true',

View file

@ -4,8 +4,6 @@ import { z } from 'zod';
import type { RedisConfig } from '@/libs/redis';
type UpstashRedisConfig = { token: string; url: string };
const parseNumber = (value?: string) => {
const parsed = Number.parseInt(value ?? '', 10);
@ -30,8 +28,6 @@ export const getRedisEnv = () => {
REDIS_TLS: parseRedisTls(process.env.REDIS_TLS),
REDIS_URL: process.env.REDIS_URL,
REDIS_USERNAME: process.env.REDIS_USERNAME,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
},
server: {
REDIS_DATABASE: z.number().int().optional(),
@ -40,67 +36,29 @@ export const getRedisEnv = () => {
REDIS_TLS: z.boolean().default(false),
REDIS_URL: z.string().url().optional(),
REDIS_USERNAME: z.string().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
},
});
};
export const redisEnv = getRedisEnv();
export const getUpstashRedisConfig = (): UpstashRedisConfig | null => {
const upstashConfigSchema = z.union([
z.object({
token: z.string(),
url: z.string().url(),
}),
z.object({
token: z.undefined().optional(),
url: z.undefined().optional(),
}),
]);
const parsed = upstashConfigSchema.safeParse({
token: redisEnv.UPSTASH_REDIS_REST_TOKEN,
url: redisEnv.UPSTASH_REDIS_REST_URL,
});
if (!parsed.success) throw parsed.error;
if (!parsed.data.token || !parsed.data.url) return null;
return parsed.data;
};
export const getRedisConfig = (): RedisConfig => {
const prefix = redisEnv.REDIS_PREFIX;
if (redisEnv.REDIS_URL) {
if (!redisEnv.REDIS_URL) {
return {
database: redisEnv.REDIS_DATABASE,
enabled: true,
password: redisEnv.REDIS_PASSWORD,
prefix,
provider: 'redis',
tls: redisEnv.REDIS_TLS,
url: redisEnv.REDIS_URL,
username: redisEnv.REDIS_USERNAME,
};
}
const upstashConfig = getUpstashRedisConfig();
if (upstashConfig) {
return {
enabled: true,
prefix,
provider: 'upstash',
token: upstashConfig.token,
url: upstashConfig.url,
enabled: false,
prefix: redisEnv.REDIS_PREFIX,
tls: false,
url: '',
};
}
return {
enabled: false,
prefix,
provider: false,
database: redisEnv.REDIS_DATABASE,
enabled: true,
password: redisEnv.REDIS_PASSWORD,
prefix: redisEnv.REDIS_PREFIX,
tls: redisEnv.REDIS_TLS,
url: redisEnv.REDIS_URL,
username: redisEnv.REDIS_USERNAME,
};
};

View file

@ -67,17 +67,6 @@ vi.mock('@/const/version', () => ({
isDesktop: false,
}));
// Use vi.hoisted to ensure variables exist before vi.mock factory executes
const { enableNextAuth } = vi.hoisted(() => ({
enableNextAuth: { value: false },
}));
vi.mock('@/envs/auth', () => ({
get enableNextAuth() {
return enableNextAuth.value;
},
}));
describe('PanelContent', () => {
const closePopover = vi.fn();

View file

@ -1,6 +1,6 @@
import { BRANDING_NAME } from '@lobechat/business-const';
import { act, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
import { useUserStore } from '@/store/user';
@ -9,21 +9,6 @@ import UserAvatar from '../UserAvatar';
vi.mock('zustand/traditional');
// Use vi.hoisted to ensure variables exist before vi.mock factory executes
const { enableNextAuth } = vi.hoisted(() => ({
enableNextAuth: { value: false },
}));
vi.mock('@/envs/auth', () => ({
get enableNextAuth() {
return enableNextAuth.value;
},
}));
afterEach(() => {
enableNextAuth.value = false;
});
describe('UserAvatar', () => {
it('should show the username and avatar are displayed when the user is logged in', async () => {
const mockAvatar = 'https://example.com/avatar.png';

View file

@ -1,44 +0,0 @@
'use client';
import { useSession } from 'next-auth/react';
import { memo, useEffect } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { useUserStore } from '@/store/user';
import { type LobeUser } from '@/types/user';
// update the user data into the context
const UserUpdater = memo(() => {
const { data: session, status } = useSession();
const isLoaded = status !== 'loading';
const isSignedIn = (status === 'authenticated' && session && !!session.user) || false;
const nextUser = session?.user;
const useStoreUpdater = createStoreUpdater(useUserStore);
useStoreUpdater('isLoaded', isLoaded);
useStoreUpdater('isSignedIn', isSignedIn);
useStoreUpdater('nextSession', session!);
// 使用 useEffect 处理需要保持同步的用户数据
useEffect(() => {
if (nextUser) {
const userAvatar = useUserStore.getState().user?.avatar;
const lobeUser = {
// 头像使用设置的,而不是从 next-auth 中获取
avatar: userAvatar || '',
email: nextUser.email,
fullName: nextUser.name,
id: nextUser.id,
} as LobeUser;
// 更新用户相关数据
useUserStore.setState({ nextUser: nextUser, user: lobeUser });
}
}, [nextUser]);
return null;
});
export default UserUpdater;

View file

@ -1,17 +0,0 @@
import { SessionProvider } from 'next-auth/react';
import { type PropsWithChildren } from 'react';
import { API_ENDPOINTS } from '@/services/_url';
import UserUpdater from './UserUpdater';
const NextAuth = ({ children }: PropsWithChildren) => {
return (
<SessionProvider basePath={API_ENDPOINTS.oauth}>
{children}
<UserUpdater />
</SessionProvider>
);
};
export default NextAuth;

View file

@ -5,7 +5,6 @@ import { authEnv } from '@/envs/auth';
import BetterAuth from './BetterAuth';
import Desktop from './Desktop';
import NextAuth from './NextAuth';
import NoAuth from './NoAuth';
const AuthProvider = ({ children }: PropsWithChildren) => {
@ -13,14 +12,10 @@ const AuthProvider = ({ children }: PropsWithChildren) => {
return <Desktop>{children}</Desktop>;
}
if (authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH) {
if (authEnv.AUTH_SECRET) {
return <BetterAuth>{children}</BetterAuth>;
}
if (authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH) {
return <NextAuth>{children}</NextAuth>;
}
return <NoAuth>{children}</NoAuth>;
};

View file

@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { createStoreUpdater } from 'zustand-utils';
import { isDesktop } from '@/const/version';
import { enableNextAuth } from '@/envs/auth';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { useAiInfraStore } from '@/store/aiInfra';
@ -25,9 +24,8 @@ const StoreInitialization = memo(() => {
// prefetch error ns to avoid don't show error content correctly
useTranslation('error');
const [isLogin, isSignedIn, useInitUserState] = useUserStore((s) => [
const [isLogin, useInitUserState] = useUserStore((s) => [
authSelectors.isLogin(s),
s.isSignedIn,
s.useInitUserState,
]);
@ -65,7 +63,7 @@ const StoreInitialization = memo(() => {
* IMPORTANT: Explicitly convert to boolean to avoid passing null/undefined downstream,
* which would cause unnecessary API requests with invalid login state.
*/
const isLoginOnInit = Boolean(enableNextAuth ? isSignedIn : isLogin);
const isLoginOnInit = Boolean(isLogin);
// init inbox agent via builtin agent mechanism
useInitBuiltinAgent(INBOX_SESSION_ID, { isLogin: isLoginOnInit });

View file

@ -23,6 +23,7 @@ import {
getVerificationOTPEmailTemplate,
} from '@/libs/better-auth/email-templates';
import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
import { emailWhitelist } from '@/libs/better-auth/plugins/email-whitelist';
import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
import { parseSSOProviders } from '@/libs/better-auth/utils/server';
import { EmailService } from '@/server/services/email';
@ -222,6 +223,7 @@ export function defineConfig(customOptions: CustomBetterAuthOptions) {
},
plugins: [
...customOptions.plugins,
emailWhitelist(),
expo(),
emailHarmony({ allowNormalizedSignin: false, validator: customEmailValidator }),
admin(),

View file

@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Get mocked module
import { authEnv } from '@/envs/auth';
import { isEmailAllowed } from './email-whitelist';
// Mock authEnv
vi.mock('@/envs/auth', () => ({
authEnv: {
AUTH_ALLOWED_EMAILS: undefined as string | undefined,
},
}));
describe('isEmailAllowed', () => {
beforeEach(() => {
// Reset to undefined before each test
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = undefined;
});
describe('when whitelist is empty', () => {
it('should allow all emails when AUTH_ALLOWED_EMAILS is undefined', () => {
expect(isEmailAllowed('anyone@example.com')).toBe(true);
});
it('should allow all emails when AUTH_ALLOWED_EMAILS is empty string', () => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = '';
expect(isEmailAllowed('anyone@example.com')).toBe(true);
});
});
describe('domain matching', () => {
beforeEach(() => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
'example.com,company.org';
});
it('should allow email from whitelisted domain', () => {
expect(isEmailAllowed('user@example.com')).toBe(true);
expect(isEmailAllowed('admin@company.org')).toBe(true);
});
it('should reject email from non-whitelisted domain', () => {
expect(isEmailAllowed('user@other.com')).toBe(false);
});
it('should be case-sensitive for domain', () => {
expect(isEmailAllowed('user@Example.com')).toBe(false);
expect(isEmailAllowed('user@EXAMPLE.COM')).toBe(false);
});
});
describe('exact email matching', () => {
beforeEach(() => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
'admin@special.com,vip@other.com';
});
it('should allow exact email match', () => {
expect(isEmailAllowed('admin@special.com')).toBe(true);
expect(isEmailAllowed('vip@other.com')).toBe(true);
});
it('should reject different email at same domain', () => {
expect(isEmailAllowed('user@special.com')).toBe(false);
});
it('should be case-sensitive for email', () => {
expect(isEmailAllowed('Admin@special.com')).toBe(false);
});
});
describe('mixed domain and email matching', () => {
beforeEach(() => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
'example.com,admin@other.com';
});
it('should allow any email from whitelisted domain', () => {
expect(isEmailAllowed('anyone@example.com')).toBe(true);
});
it('should allow specific whitelisted email', () => {
expect(isEmailAllowed('admin@other.com')).toBe(true);
});
it('should reject non-whitelisted email from non-whitelisted domain', () => {
expect(isEmailAllowed('user@other.com')).toBe(false);
});
});
describe('whitespace handling', () => {
it('should trim whitespace from whitelist entries', () => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
' example.com , admin@other.com ';
expect(isEmailAllowed('user@example.com')).toBe(true);
expect(isEmailAllowed('admin@other.com')).toBe(true);
});
it('should filter empty entries', () => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
'example.com,,other.com';
expect(isEmailAllowed('user@example.com')).toBe(true);
expect(isEmailAllowed('user@other.com')).toBe(true);
});
});
describe('edge cases', () => {
it('should reject malformed email without @', () => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = 'example.com';
expect(isEmailAllowed('invalid-email')).toBe(false);
});
it('should handle email with multiple @ symbols', () => {
(authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = 'example.com';
// split('@')[1] returns 'middle@example.com', which won't match 'example.com'
expect(isEmailAllowed('user@middle@example.com')).toBe(false);
});
});
});

View file

@ -0,0 +1,62 @@
import { APIError } from 'better-auth/api';
import { type BetterAuthPlugin } from 'better-auth/types';
import { authEnv } from '@/envs/auth';
/**
* Parse comma-separated email whitelist string into array.
*/
function parseAllowedEmails(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Check if email is allowed based on whitelist.
* Supports full email (user@example.com) or domain (example.com).
*/
export function isEmailAllowed(email: string): boolean {
const allowedList = parseAllowedEmails(authEnv.AUTH_ALLOWED_EMAILS);
if (allowedList.length === 0) return true;
const domain = email.split('@')[1];
return allowedList.some((item) => {
// Full email match
if (item.includes('@')) return item === email;
// Domain match
return item === domain;
});
}
/**
* Better Auth plugin to restrict registration to whitelisted emails/domains.
* Intercepts user creation (both email signup and SSO) via databaseHooks.
*/
export const emailWhitelist = (): BetterAuthPlugin => ({
id: 'email-whitelist',
init() {
return {
options: {
databaseHooks: {
user: {
create: {
before: async (user) => {
if (!user.email) return { data: user };
if (!isEmailAllowed(user.email)) {
throw new APIError('FORBIDDEN', { message: 'Email not allowed for registration' });
}
return { data: user };
},
},
},
},
},
};
},
});

View file

@ -1,177 +0,0 @@
import type {
AdapterAuthenticator,
AdapterSession,
AdapterUser,
VerificationToken,
} from '@auth/core/adapters';
import debug from 'debug';
import { type Adapter, type AdapterAccount } from 'next-auth/adapters';
import urlJoin from 'url-join';
import { serverDBEnv } from '@/config/db';
import { appEnv } from '@/envs/app';
const log = debug('lobe-next-auth:adapter');
interface BackendAdapterResponse {
data?: any;
error?: string;
success: boolean;
}
// Due to use direct HTTP Post, the date string cannot parse automatically
export const dateKeys = ['expires', 'emailVerified'];
/**
* @description LobeNextAuthDbAdapter is implemented to handle the database operations for NextAuth
* @returns {Adapter}
*/
export function LobeNextAuthDbAdapter(): Adapter {
const baseUrl = appEnv.APP_URL;
// Ensure the baseUrl is set, otherwise throw an error
if (!baseUrl) {
throw new Error('LobeNextAuthDbAdapter: APP_URL is not set in environment variables');
}
const interactionUrl = urlJoin(baseUrl, '/api/auth/adapter');
log(`LobeNextAuthDbAdapter initialized with url: ${interactionUrl}`);
// Ensure serverDBEnv.KEY_VAULTS_SECRET is set, otherwise throw an error
if (!serverDBEnv.KEY_VAULTS_SECRET) {
throw new Error('LobeNextAuthDbAdapter: KEY_VAULTS_SECRET is not set in environment variables');
}
const fetcher = (action: string, data: any) =>
fetch(interactionUrl, {
body: JSON.stringify({ action, data }),
headers: {
'Authorization': `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
const postProcessor = async (res: Response) => {
const data = (await res.json()) as BackendAdapterResponse;
log('LobeNextAuthDbAdapter: postProcessor called with data:', data);
if (!data.success) {
log('LobeNextAuthDbAdapter: Error in postProcessor:');
log(data);
throw new Error(`LobeNextAuthDbAdapter: ${data.error}`);
}
if (data?.data) {
for (const key of dateKeys) {
if (data.data[key]) {
data.data[key] = new Date(data.data[key]);
continue;
}
}
}
return data.data;
};
return {
async createAuthenticator(authenticator): Promise<AdapterAuthenticator> {
const data = await fetcher('createAuthenticator', authenticator);
return await postProcessor(data);
},
async createSession(session): Promise<AdapterSession> {
const data = await fetcher('createSession', session);
return await postProcessor(data);
},
async createUser(user): Promise<AdapterUser> {
const data = await fetcher('createUser', user);
return await postProcessor(data);
},
async createVerificationToken(data): Promise<VerificationToken | null | undefined> {
const result = await fetcher('createVerificationToken', data);
return await postProcessor(result);
},
async deleteSession(sessionToken): Promise<AdapterSession | null | undefined> {
const result = await fetcher('deleteSession', sessionToken);
await postProcessor(result);
return;
},
async deleteUser(id): Promise<AdapterUser | null | undefined> {
const result = await fetcher('deleteUser', id);
await postProcessor(result);
return;
},
async getAccount(providerAccountId, provider): Promise<AdapterAccount | null> {
const data = await fetcher('getAccount', {
provider,
providerAccountId,
});
return await postProcessor(data);
},
async getAuthenticator(credentialID): Promise<AdapterAuthenticator | null> {
const result = await fetcher('getAuthenticator', credentialID);
return await postProcessor(result);
},
async getSessionAndUser(sessionToken): Promise<{
session: AdapterSession;
user: AdapterUser;
} | null> {
const result = await fetcher('getSessionAndUser', sessionToken);
return await postProcessor(result);
},
async getUser(id): Promise<AdapterUser | null> {
log('getUser called with id:', id);
const result = await fetcher('getUser', id);
return await postProcessor(result);
},
async getUserByAccount(account): Promise<AdapterUser | null> {
const data = await fetcher('getUserByAccount', account);
return await postProcessor(data);
},
async getUserByEmail(email): Promise<AdapterUser | null> {
const data = await fetcher('getUserByEmail', email);
return await postProcessor(data);
},
async linkAccount(data): Promise<AdapterAccount | null | undefined> {
const result = await fetcher('linkAccount', data);
return await postProcessor(result);
},
async listAuthenticatorsByUserId(userId): Promise<AdapterAuthenticator[]> {
const result = await fetcher('listAuthenticatorsByUserId', userId);
return await postProcessor(result);
},
// @ts-ignore: The return type is {Promise<void> | Awaitable<AdapterAccount | undefined>}
async unlinkAccount(account): Promise<void | AdapterAccount | undefined> {
const result = await fetcher('unlinkAccount', account);
await postProcessor(result);
return;
},
async updateAuthenticatorCounter(credentialID, counter): Promise<AdapterAuthenticator> {
const result = await fetcher('updateAuthenticatorCounter', {
counter,
credentialID,
});
return await postProcessor(result);
},
async updateSession(data): Promise<AdapterSession | null | undefined> {
const result = await fetcher('updateSession', data);
return await postProcessor(result);
},
async updateUser(user): Promise<AdapterUser> {
const result = await fetcher('updateUser', user);
return await postProcessor(result);
},
async useVerificationToken(identifier_token): Promise<VerificationToken | null> {
const result = await fetcher('useVerificationToken', identifier_token);
return await postProcessor(result);
},
};
}

View file

@ -1,64 +0,0 @@
import type { NextAuthConfig } from 'next-auth';
import { getAuthConfig } from '@/envs/auth';
import { LobeNextAuthDbAdapter } from './adapter';
import { ssoProviders } from './sso-providers';
const {
NEXT_AUTH_DEBUG,
NEXT_AUTH_SECRET,
NEXT_AUTH_SSO_SESSION_STRATEGY,
NEXT_AUTH_SSO_PROVIDERS,
NEXT_PUBLIC_ENABLE_NEXT_AUTH,
} = getAuthConfig();
export const initSSOProviders = () => {
return NEXT_PUBLIC_ENABLE_NEXT_AUTH
? NEXT_AUTH_SSO_PROVIDERS.split(/[,]/).map((provider) => {
const validProvider = ssoProviders.find((item) => item.id === provider.trim());
if (validProvider) return validProvider.provider;
throw new Error(`[NextAuth] provider ${provider} is not supported`);
})
: [];
};
// Notice this is only an object, not a full Auth.js instance
export default {
adapter: NEXT_PUBLIC_ENABLE_NEXT_AUTH ? LobeNextAuthDbAdapter() : undefined,
callbacks: {
// Note: Data processing order of callback: authorize --> jwt --> session
async jwt({ token, user }) {
// ref: https://authjs.dev/guides/extending-the-session#with-jwt
if (user?.id) {
token.userId = user?.id;
}
return token;
},
async session({ session, token, user }) {
if (session.user) {
// ref: https://authjs.dev/guides/extending-the-session#with-database
if (user) {
session.user.id = user.id;
} else {
session.user.id = (token.userId ?? session.user.id) as string;
}
}
return session;
},
},
debug: NEXT_AUTH_DEBUG,
pages: {
error: '/next-auth/error',
signIn: '/next-auth/signin',
},
providers: initSSOProviders(),
secret: NEXT_AUTH_SECRET ?? process.env.AUTH_SECRET,
session: {
// Force use JWT if server service is disabled
strategy: NEXT_AUTH_SSO_SESSION_STRATEGY,
},
trustHost: process.env?.AUTH_TRUST_HOST ? process.env.AUTH_TRUST_HOST === 'true' : true,
} satisfies NextAuthConfig;

View file

@ -1,20 +0,0 @@
import NextAuth from 'next-auth';
import authConfig from './auth.config';
/**
* NextAuth initialization without Database adapter
*
* @note
* We currently use `jwt` strategy for session management.
* So you don't need to import `signIn` or `signOut` from
* this module, just import from `next-auth` directly.
*
* Inside react component
* @example
* ```ts
* import { signOut } from 'next-auth/react';
* signOut();
* ```
*/
export default NextAuth(authConfig);

View file

@ -1,24 +0,0 @@
import Auth0 from 'next-auth/providers/auth0';
import { CommonProviderConfig } from './sso.config';
const provider = {
id: 'auth0',
provider: Auth0({
...CommonProviderConfig,
// Specify auth scope, at least include 'openid email'
// all scopes in Auth0 ref: https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes#standard-claims
authorization: { params: { scope: 'openid email profile' } },
profile(profile) {
return {
email: profile.email,
id: profile.sub,
image: profile.picture,
name: profile.name,
providerAccountId: profile.sub,
};
},
}),
};
export default provider;

View file

@ -1,39 +0,0 @@
import type { OIDCConfig } from '@auth/core/providers';
import { CommonProviderConfig } from './sso.config';
export type AutheliaProfile = {
// The users display name
email: string;
// The users email
groups: string[];
// The username the user used to login with
name: string;
preferred_username: string; // The users groups
sub: string; // The users id
};
const provider = {
id: 'authelia',
provider: {
...CommonProviderConfig,
authorization: { params: { scope: 'openid email profile' } },
checks: ['state', 'pkce'],
clientId: process.env.AUTH_AUTHELIA_ID,
clientSecret: process.env.AUTH_AUTHELIA_SECRET,
id: 'authelia',
issuer: process.env.AUTH_AUTHELIA_ISSUER,
name: 'Authelia',
profile(profile) {
return {
email: profile.email,
id: profile.sub,
name: profile.name,
providerAccountId: profile.sub,
};
},
type: 'oidc',
} satisfies OIDCConfig<AutheliaProfile>,
};
export default provider;

View file

@ -1,25 +0,0 @@
import Authentik from 'next-auth/providers/authentik';
import { CommonProviderConfig } from './sso.config';
const provider = {
id: 'authentik',
provider: Authentik({
...CommonProviderConfig,
// Specify auth scope, at least include 'openid email'
// all scopes in Authentik ref: https://goauthentik.io/docs/providers/oauth2
authorization: { params: { scope: 'openid email profile' } },
// TODO(NextAuth): map unique user id to `providerAccountId` field
// profile(profile) {
// return {
// email: profile.email,
// image: profile.picture,
// name: profile.name,
// providerAccountId: profile.user_id,
// id: profile.user_id,
// };
// },
}),
};
export default provider;

View file

@ -1,50 +0,0 @@
import { type OIDCConfig, type OIDCUserConfig } from '@auth/core/providers';
import { CommonProviderConfig } from './sso.config';
interface CasdoorProfile extends Record<string, any> {
avatar: string;
displayName: string;
email: string;
emailVerified: boolean;
firstName: string;
id: string;
lastName: string;
name: string;
owner: string;
permanentAvatar: string;
}
function LobeCasdoorProvider(config: OIDCUserConfig<CasdoorProfile>): OIDCConfig<CasdoorProfile> {
return {
...CommonProviderConfig,
...config,
id: 'casdoor',
name: 'Casdoor',
profile(profile) {
return {
email: profile.email,
emailVerified: profile.emailVerified ? new Date() : null,
id: profile.id,
image: profile.avatar,
name: profile.displayName ?? profile.firstName ?? profile.lastName,
providerAccountId: profile.id,
};
},
type: 'oidc',
};
}
const provider = {
id: 'casdoor',
provider: LobeCasdoorProvider({
authorization: {
params: { scope: 'openid profile email' },
},
clientId: process.env.AUTH_CASDOOR_ID,
clientSecret: process.env.AUTH_CASDOOR_SECRET,
issuer: process.env.AUTH_CASDOOR_ISSUER,
}),
};
export default provider;

View file

@ -1,34 +0,0 @@
import type { OIDCConfig } from '@auth/core/providers';
import { CommonProviderConfig } from './sso.config';
export type CloudflareZeroTrustProfile = {
email: string;
name: string;
sub: string;
};
const provider = {
id: 'cloudflare-zero-trust',
provider: {
...CommonProviderConfig,
authorization: { params: { scope: 'openid email profile' } },
checks: ['state', 'pkce'],
clientId: process.env.AUTH_CLOUDFLARE_ZERO_TRUST_ID,
clientSecret: process.env.AUTH_CLOUDFLARE_ZERO_TRUST_SECRET,
id: 'cloudflare-zero-trust',
issuer: process.env.AUTH_CLOUDFLARE_ZERO_TRUST_ISSUER,
name: 'Cloudflare Zero Trust',
profile(profile) {
return {
email: profile.email,
id: profile.sub,
name: profile.name ?? profile.email,
providerAccountId: profile.sub,
};
},
type: 'oidc',
} satisfies OIDCConfig<CloudflareZeroTrustProfile>,
};
export default provider;

View file

@ -1,8 +0,0 @@
import Cognito from 'next-auth/providers/cognito';
const provider = {
id: 'cognito',
provider: Cognito({}),
};
export default provider;

Some files were not shown because too many files have changed in this diff Show more