mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat: remove NextAuth (#11732)
This commit is contained in:
parent
0fcf8b0def
commit
1eff8646f7
145 changed files with 2989 additions and 3563 deletions
|
|
@ -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
|
||||
|
|
|
|||
36
.env.example
36
.env.example
|
|
@ -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 #############
|
||||
# #######################################
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
.github/workflows/e2e.yml
vendored
21
.github/workflows/e2e.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
32
Dockerfile
32
Dockerfile
|
|
@ -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="" \
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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 to:https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
|
||||
AUTH_ZITADEL_ID=285945938244075523
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 to:https://lobehub.com/zh/docs/self-hosting/advanced/auth/next-auth/zitadel
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 配置 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`(端口 465),TLS 设置为 `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`(端口 465),TLS 设置为 `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 登录均有效。
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 的环境变量中设置以下变量:
|
||||
|
||||
| 环境变量 | 类型 | 描述 |
|
||||
|
|
|
|||
326
docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx
Normal file
326
docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx
Normal 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>
|
||||
323
docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx
Normal file
323
docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx
Normal 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
|
||||
```
|
||||
|
||||
### 步骤 2:Dry-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
|
||||
```
|
||||
|
||||
验证测试环境迁移结果无误后,继续下一步。
|
||||
|
||||
### 步骤 4:Dry-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>
|
||||
128
docs/self-hosting/advanced/redis.mdx
Normal file
128
docs/self-hosting/advanced/redis.mdx
Normal 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
|
||||
126
docs/self-hosting/advanced/redis.zh-CN.mdx
Normal file
126
docs/self-hosting/advanced/redis.zh-CN.mdx
Normal 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 实现高可用
|
||||
69
docs/self-hosting/advanced/redis/upstash.mdx
Normal file
69
docs/self-hosting/advanced/redis/upstash.mdx
Normal 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
|
||||
69
docs/self-hosting/advanced/redis/upstash.zh-CN.mdx
Normal file
69
docs/self-hosting/advanced/redis/upstash.zh-CN.mdx
Normal 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
|
||||
|
||||
# 可选:启用 TLS(Upstash 推荐)
|
||||
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 可获得更低延迟
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
- Type:Optional
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
- 类型:可选
|
||||
|
|
|
|||
68
docs/self-hosting/environment-variables/redis.mdx
Normal file
68
docs/self-hosting/environment-variables/redis.mdx
Normal 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`
|
||||
67
docs/self-hosting/environment-variables/redis.zh-CN.mdx
Normal file
67
docs/self-hosting/environment-variables/redis.zh-CN.mdx
Normal 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`
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
||||
## 数据库模式变更
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 变量(必需)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
99
scripts/_shared/checkDeprecatedAuth.js
Normal file
99
scripts/_shared/checkDeprecatedAuth.js
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
41
scripts/nextauth-to-betterauth/_internal/config.ts
Normal file
41
scripts/nextauth-to-betterauth/_internal/config.ts
Normal 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';
|
||||
}
|
||||
32
scripts/nextauth-to-betterauth/_internal/db.ts
Normal file
32
scripts/nextauth-to-betterauth/_internal/db.ts
Normal 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';
|
||||
6
scripts/nextauth-to-betterauth/_internal/env.ts
Normal file
6
scripts/nextauth-to-betterauth/_internal/env.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
|
||||
if (existsSync('.env')) {
|
||||
loadEnvFile();
|
||||
}
|
||||
226
scripts/nextauth-to-betterauth/index.ts
Normal file
226
scripts/nextauth-to-betterauth/index.ts
Normal 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();
|
||||
188
scripts/nextauth-to-betterauth/verify.ts
Normal file
188
scripts/nextauth-to-betterauth/verify.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
// 执行删除操作
|
||||
|
|
|
|||
|
|
@ -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('-------------------------------------');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
120
src/libs/better-auth/plugins/email-whitelist.test.ts
Normal file
120
src/libs/better-auth/plugins/email-whitelist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/libs/better-auth/plugins/email-whitelist.ts
Normal file
62
src/libs/better-auth/plugins/email-whitelist.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Reference in a new issue