From 1eff8646f73eb74edba63a9d5a7f18e5bc0f0320 Mon Sep 17 00:00:00 2001 From: YuTengjing Date: Fri, 23 Jan 2026 23:57:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20remove=20NextAuth=20(#11732?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.desktop | 1 - .env.example | 36 +- .env.example.development | 5 +- .github/workflows/e2e.yml | 21 +- Dockerfile | 32 +- docker-compose/local/docker-compose.yml | 4 +- .../local/grafana/docker-compose.yml | 4 +- docker-compose/local/logto/docker-compose.yml | 4 +- docker-compose/local/zitadel/.env.example | 4 +- .../local/zitadel/.env.zh-CN.example | 4 +- .../production/grafana/docker-compose.yml | 4 +- docker-compose/production/logto/.env.example | 4 +- .../production/logto/.env.zh-CN.example | 4 +- .../production/zitadel/.env.example | 4 +- .../production/zitadel/.env.zh-CN.example | 4 +- .../add-new-authentication-providers.mdx | 280 +++++++-------- ...add-new-authentication-providers.zh-CN.mdx | 284 +++++++-------- docs/self-hosting/advanced/auth.mdx | 59 +++- docs/self-hosting/advanced/auth.zh-CN.mdx | 60 +++- docs/self-hosting/advanced/auth/legacy.mdx | 4 + .../advanced/auth/legacy.zh-CN.mdx | 4 + .../advanced/auth/nextauth-to-betterauth.mdx | 326 ++++++++++++++++++ .../auth/nextauth-to-betterauth.zh-CN.mdx | 323 +++++++++++++++++ docs/self-hosting/advanced/redis.mdx | 128 +++++++ docs/self-hosting/advanced/redis.zh-CN.mdx | 126 +++++++ docs/self-hosting/advanced/redis/upstash.mdx | 69 ++++ .../advanced/redis/upstash.zh-CN.mdx | 69 ++++ .../environment-variables/auth.mdx | 16 +- .../environment-variables/auth.zh-CN.mdx | 16 +- .../environment-variables/basic.mdx | 13 + .../environment-variables/basic.zh-CN.mdx | 13 + .../environment-variables/redis.mdx | 68 ++++ .../environment-variables/redis.zh-CN.mdx | 67 ++++ .../migration/v2/breaking-changes.mdx | 46 +-- .../migration/v2/breaking-changes.zh-CN.mdx | 46 +-- .../server-database/docker-compose.mdx | 8 +- .../server-database/docker-compose.zh-CN.mdx | 8 +- e2e/CLAUDE.md | 11 +- e2e/docs/local-setup.md | 21 +- e2e/scripts/setup.ts | 24 +- e2e/src/support/webServer.ts | 13 +- package.json | 8 +- packages/database/src/schemas/nextauth.ts | 9 +- .../utils/src/server/__tests__/auth.test.ts | 64 +--- packages/utils/src/server/auth.ts | 32 +- scripts/_shared/checkDeprecatedAuth.js | 99 ++++++ scripts/_shared/checkDeprecatedClerkEnv.js | 42 --- scripts/clerk-to-betterauth/index.ts | 11 +- .../_internal/config.ts | 41 +++ .../nextauth-to-betterauth/_internal/db.ts | 32 ++ .../nextauth-to-betterauth/_internal/env.ts | 6 + scripts/nextauth-to-betterauth/index.ts | 226 ++++++++++++ scripts/nextauth-to-betterauth/verify.ts | 188 ++++++++++ scripts/prebuild.mts | 79 ++++- scripts/serverLauncher/startServer.js | 10 +- src/app/(backend)/api/auth/[...all]/route.ts | 28 +- src/app/(backend)/api/auth/adapter/route.ts | 137 -------- .../(backend)/api/webhooks/casdoor/route.ts | 10 +- src/app/(backend)/api/webhooks/logto/route.ts | 16 +- .../(backend)/middleware/auth/index.test.ts | 9 +- src/app/(backend)/middleware/auth/index.ts | 21 +- .../(backend)/middleware/auth/utils.test.ts | 32 -- src/app/(backend)/middleware/auth/utils.ts | 11 +- .../webapi/chat/[provider]/route.test.ts | 9 +- .../webapi/create-image/comfyui/route.ts | 1 - .../webapi/models/[provider]/route.test.ts | 9 +- .../(auth)/next-auth/error/AuthErrorPage.tsx | 40 --- .../(auth)/next-auth/error/page.tsx | 11 - .../(auth)/next-auth/signin/AuthSignInBox.tsx | 167 --------- .../(auth)/next-auth/signin/page.tsx | 11 - .../(auth)/reset-password/layout.tsx | 12 - .../(auth)/signin/SignInEmailStep.tsx | 2 +- src/app/[variants]/(auth)/signin/layout.tsx | 12 - .../(auth)/signup/[[...signup]]/page.tsx | 21 +- .../[variants]/(auth)/verify-email/layout.tsx | 12 - .../features/SSOProvidersList/index.tsx | 31 +- .../(main)/settings/profile/index.tsx | 22 +- src/components/{NextAuth => }/AuthIcons.tsx | 18 +- src/envs/auth.test.ts | 47 --- src/envs/auth.ts | 63 +--- src/envs/email.ts | 3 + src/envs/redis.ts | 66 +--- .../User/__tests__/PanelContent.test.tsx | 11 - .../User/__tests__/UserAvatar.test.tsx | 17 +- .../AuthProvider/NextAuth/UserUpdater.tsx | 44 --- src/layout/AuthProvider/NextAuth/index.tsx | 17 - src/layout/AuthProvider/index.tsx | 7 +- .../GlobalProvider/StoreInitialization.tsx | 6 +- src/libs/better-auth/define-config.ts | 2 + .../plugins/email-whitelist.test.ts | 120 +++++++ .../better-auth/plugins/email-whitelist.ts | 62 ++++ src/libs/next-auth/adapter/index.ts | 177 ---------- src/libs/next-auth/auth.config.ts | 64 ---- src/libs/next-auth/index.ts | 20 -- src/libs/next-auth/sso-providers/auth0.ts | 24 -- src/libs/next-auth/sso-providers/authelia.ts | 39 --- src/libs/next-auth/sso-providers/authentik.ts | 25 -- src/libs/next-auth/sso-providers/casdoor.ts | 50 --- .../sso-providers/cloudflare-zero-trust.ts | 34 -- src/libs/next-auth/sso-providers/cognito.ts | 8 - src/libs/next-auth/sso-providers/feishu.ts | 83 ----- .../next-auth/sso-providers/generic-oidc.ts | 38 -- src/libs/next-auth/sso-providers/github.ts | 23 -- src/libs/next-auth/sso-providers/google.ts | 18 - src/libs/next-auth/sso-providers/index.ts | 35 -- src/libs/next-auth/sso-providers/keycloak.ts | 22 -- src/libs/next-auth/sso-providers/logto.ts | 48 --- .../microsoft-entra-id-helper.ts | 29 -- .../sso-providers/microsoft-entra-id.ts | 19 - src/libs/next-auth/sso-providers/okta.ts | 22 -- .../next-auth/sso-providers/sso.config.ts | 8 - src/libs/next-auth/sso-providers/wechat.ts | 36 -- src/libs/next-auth/sso-providers/zitadel.ts | 21 -- src/libs/next/config/define-config.ts | 14 +- src/libs/next/proxy/define-config.ts | 77 +---- src/libs/oidc-provider/provider.test.ts | 4 - src/libs/redis/index.ts | 1 - src/libs/redis/manager.test.ts | 54 +-- src/libs/redis/manager.ts | 18 +- src/libs/redis/redis.test.ts | 6 +- src/libs/redis/redis.ts | 6 +- src/libs/redis/types.ts | 26 +- src/libs/redis/upstash.test.ts | 158 --------- src/libs/redis/upstash.ts | 136 -------- src/libs/redis/utils.test.ts | 10 - src/libs/redis/utils.ts | 19 - src/libs/trpc/lambda/context.test.ts | 13 - src/libs/trpc/lambda/context.ts | 78 ++--- src/libs/trpc/middleware/userAuth.ts | 8 +- src/libs/trusted-client/getSessionUser.ts | 48 +-- src/server/globalConfig/index.ts | 4 +- .../routers/lambda/__tests__/user.test.ts | 48 --- src/server/routers/lambda/user.ts | 13 +- .../services/email/impls/nodemailer/index.ts | 4 +- src/server/services/nextAuthUser/index.ts | 318 ----------------- src/server/services/nextAuthUser/utils.ts | 62 ---- src/server/services/webhookUser/index.ts | 88 +++++ src/services/user/index.test.ts | 14 - src/services/user/index.ts | 4 - src/store/user/slices/auth/action.test.ts | 148 ++------ src/store/user/slices/auth/action.ts | 97 ++---- src/store/user/slices/auth/initialState.ts | 3 - src/store/user/slices/auth/selectors.ts | 3 - src/types/next-auth.d.ts | 26 -- tests/setup.ts | 10 + 145 files changed, 2989 insertions(+), 3563 deletions(-) create mode 100644 docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx create mode 100644 docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx create mode 100644 docs/self-hosting/advanced/redis.mdx create mode 100644 docs/self-hosting/advanced/redis.zh-CN.mdx create mode 100644 docs/self-hosting/advanced/redis/upstash.mdx create mode 100644 docs/self-hosting/advanced/redis/upstash.zh-CN.mdx create mode 100644 docs/self-hosting/environment-variables/redis.mdx create mode 100644 docs/self-hosting/environment-variables/redis.zh-CN.mdx create mode 100644 scripts/_shared/checkDeprecatedAuth.js delete mode 100644 scripts/_shared/checkDeprecatedClerkEnv.js create mode 100644 scripts/nextauth-to-betterauth/_internal/config.ts create mode 100644 scripts/nextauth-to-betterauth/_internal/db.ts create mode 100644 scripts/nextauth-to-betterauth/_internal/env.ts create mode 100644 scripts/nextauth-to-betterauth/index.ts create mode 100644 scripts/nextauth-to-betterauth/verify.ts delete mode 100644 src/app/(backend)/api/auth/adapter/route.ts delete mode 100644 src/app/[variants]/(auth)/next-auth/error/AuthErrorPage.tsx delete mode 100644 src/app/[variants]/(auth)/next-auth/error/page.tsx delete mode 100644 src/app/[variants]/(auth)/next-auth/signin/AuthSignInBox.tsx delete mode 100644 src/app/[variants]/(auth)/next-auth/signin/page.tsx delete mode 100644 src/app/[variants]/(auth)/reset-password/layout.tsx delete mode 100644 src/app/[variants]/(auth)/signin/layout.tsx delete mode 100644 src/app/[variants]/(auth)/verify-email/layout.tsx rename src/components/{NextAuth => }/AuthIcons.tsx (70%) delete mode 100644 src/envs/auth.test.ts delete mode 100644 src/layout/AuthProvider/NextAuth/UserUpdater.tsx delete mode 100644 src/layout/AuthProvider/NextAuth/index.tsx create mode 100644 src/libs/better-auth/plugins/email-whitelist.test.ts create mode 100644 src/libs/better-auth/plugins/email-whitelist.ts delete mode 100644 src/libs/next-auth/adapter/index.ts delete mode 100644 src/libs/next-auth/auth.config.ts delete mode 100644 src/libs/next-auth/index.ts delete mode 100644 src/libs/next-auth/sso-providers/auth0.ts delete mode 100644 src/libs/next-auth/sso-providers/authelia.ts delete mode 100644 src/libs/next-auth/sso-providers/authentik.ts delete mode 100644 src/libs/next-auth/sso-providers/casdoor.ts delete mode 100644 src/libs/next-auth/sso-providers/cloudflare-zero-trust.ts delete mode 100644 src/libs/next-auth/sso-providers/cognito.ts delete mode 100644 src/libs/next-auth/sso-providers/feishu.ts delete mode 100644 src/libs/next-auth/sso-providers/generic-oidc.ts delete mode 100644 src/libs/next-auth/sso-providers/github.ts delete mode 100644 src/libs/next-auth/sso-providers/google.ts delete mode 100644 src/libs/next-auth/sso-providers/index.ts delete mode 100644 src/libs/next-auth/sso-providers/keycloak.ts delete mode 100644 src/libs/next-auth/sso-providers/logto.ts delete mode 100644 src/libs/next-auth/sso-providers/microsoft-entra-id-helper.ts delete mode 100644 src/libs/next-auth/sso-providers/microsoft-entra-id.ts delete mode 100644 src/libs/next-auth/sso-providers/okta.ts delete mode 100644 src/libs/next-auth/sso-providers/sso.config.ts delete mode 100644 src/libs/next-auth/sso-providers/wechat.ts delete mode 100644 src/libs/next-auth/sso-providers/zitadel.ts delete mode 100644 src/libs/redis/upstash.test.ts delete mode 100644 src/libs/redis/upstash.ts delete mode 100644 src/server/services/nextAuthUser/index.ts delete mode 100644 src/server/services/nextAuthUser/utils.ts create mode 100644 src/server/services/webhookUser/index.ts delete mode 100644 src/types/next-auth.d.ts diff --git a/.env.desktop b/.env.desktop index 1e00b9ccca..0428afb496 100644 --- a/.env.desktop +++ b/.env.desktop @@ -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 diff --git a/.env.example b/.env.example index 93dc2e7871..9ab43b55b7 100644 --- a/.env.example +++ b/.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 ############# # ####################################### diff --git a/.env.example.development b/.env.example.development index 122950735b..a34832aad8 100644 --- a/.env.example.development +++ b/.env.example.development @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e8fb836800..dd4966731d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index e7ba4b1e7f..ef05aa123f 100644 --- a/Dockerfile +++ b/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="" \ diff --git a/docker-compose/local/docker-compose.yml b/docker-compose/local/docker-compose.yml index c7561806b4..02b1fb4007 100644 --- a/docker-compose/local/docker-compose.yml +++ b/docker-compose/local/docker-compose.yml @@ -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' diff --git a/docker-compose/local/grafana/docker-compose.yml b/docker-compose/local/grafana/docker-compose.yml index 6f71e718f7..3e56fb1060 100644 --- a/docker-compose/local/grafana/docker-compose.yml +++ b/docker-compose/local/grafana/docker-compose.yml @@ -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' diff --git a/docker-compose/local/logto/docker-compose.yml b/docker-compose/local/logto/docker-compose.yml index 2c8f8b0db5..a4e6596aa8 100644 --- a/docker-compose/local/logto/docker-compose.yml +++ b/docker-compose/local/logto/docker-compose.yml @@ -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}' diff --git a/docker-compose/local/zitadel/.env.example b/docker-compose/local/zitadel/.env.example index deda97524b..b0aac95ad1 100644 --- a/docker-compose/local/zitadel/.env.example +++ b/docker-compose/local/zitadel/.env.example @@ -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 diff --git a/docker-compose/local/zitadel/.env.zh-CN.example b/docker-compose/local/zitadel/.env.zh-CN.example index ce7316f307..a989c1e9a6 100644 --- a/docker-compose/local/zitadel/.env.zh-CN.example +++ b/docker-compose/local/zitadel/.env.zh-CN.example @@ -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 diff --git a/docker-compose/production/grafana/docker-compose.yml b/docker-compose/production/grafana/docker-compose.yml index 4d09d911f5..ebbf874007 100644 --- a/docker-compose/production/grafana/docker-compose.yml +++ b/docker-compose/production/grafana/docker-compose.yml @@ -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' diff --git a/docker-compose/production/logto/.env.example b/docker-compose/production/logto/.env.example index b65a7b13ad..fb6bba3415 100644 --- a/docker-compose/production/logto/.env.example +++ b/docker-compose/production/logto/.env.example @@ -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 diff --git a/docker-compose/production/logto/.env.zh-CN.example b/docker-compose/production/logto/.env.zh-CN.example index 6fcba15298..c8868ce424 100644 --- a/docker-compose/production/logto/.env.zh-CN.example +++ b/docker-compose/production/logto/.env.zh-CN.example @@ -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 diff --git a/docker-compose/production/zitadel/.env.example b/docker-compose/production/zitadel/.env.example index 410630f3ff..8a07b87163 100644 --- a/docker-compose/production/zitadel/.env.example +++ b/docker-compose/production/zitadel/.env.example @@ -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 diff --git a/docker-compose/production/zitadel/.env.zh-CN.example b/docker-compose/production/zitadel/.env.zh-CN.example index 0184b69106..70172fdfca 100644 --- a/docker-compose/production/zitadel/.env.zh-CN.example +++ b/docker-compose/production/zitadel/.env.zh-CN.example @@ -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 diff --git a/docs/development/basic/add-new-authentication-providers.mdx b/docs/development/basic/add-new-authentication-providers.mdx index 33a14ac469..2f059403c9 100644 --- a/docs/development/basic/add-new-authentication-providers.mdx +++ b/docs/development/basic/add-new-authentication-providers.mdx @@ -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 | diff --git a/docs/development/basic/add-new-authentication-providers.zh-CN.mdx b/docs/development/basic/add-new-authentication-providers.zh-CN.mdx index 8b16ffd9fc..c006516757 100644 --- a/docs/development/basic/add-new-authentication-providers.zh-CN.mdx +++ b/docs/development/basic/add-new-authentication-providers.zh-CN.mdx @@ -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 配置 | diff --git a/docs/self-hosting/advanced/auth.mdx b/docs/self-hosting/advanced/auth.mdx index 77315df4f0..3111c486c7 100644 --- a/docs/self-hosting/advanced/auth.mdx +++ b/docs/self-hosting/advanced/auth.mdx @@ -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` | 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` | 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. +## 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` | + + + 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. + + ## 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. diff --git a/docs/self-hosting/advanced/auth.zh-CN.mdx b/docs/self-hosting/advanced/auth.zh-CN.mdx index 9ca0867c0f..d0c08fe226 100644 --- a/docs/self-hosting/advanced/auth.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth.zh-CN.mdx @@ -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` | 使用 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` | 使用 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 相关变量详情。 +## 会话存储配置(可选) + +默认情况下,Better Auth 使用数据库存储会话数据。你可以配置 Redis 作为二级存储,以获得更好的性能和跨实例会话共享能力。 + +| 环境变量 | 类型 | 描述 | +| -------------- | -- | ------------------------------- | +| `REDIS_URL` | 可选 | Redis 连接 URL,配置后自动启用 Redis 会话存储 | +| `REDIS_PREFIX` | 可选 | Redis 键前缀,默认为 `lobechat` | + + + 配置 Redis 后,认证会话数据将存储在 Redis 中,可以实现跨多个服务实例的会话共享,并提升会话验证速度。详细配置请参阅 [Redis 缓存服务](/zh/docs/self-hosting/advanced/redis)。 + + ## 常见问题 ### 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 登录均有效。 diff --git a/docs/self-hosting/advanced/auth/legacy.mdx b/docs/self-hosting/advanced/auth/legacy.mdx index 5692f51112..0826c6943a 100644 --- a/docs/self-hosting/advanced/auth/legacy.mdx +++ b/docs/self-hosting/advanced/auth/legacy.mdx @@ -36,6 +36,10 @@ By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CL ## Next Auth + + To migrate from NextAuth to Better Auth, see the [NextAuth Migration Guide](/docs/self-hosting/advanced/auth/nextauth-to-betterauth). + + Before using NextAuth, please set the following variables in LobeChat's environment variables: | Environment Variable | Type | Description | diff --git a/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx b/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx index f01a1c7890..c5e9332331 100644 --- a/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx +++ b/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx @@ -34,6 +34,10 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供安全、便捷的 ## Next Auth + + 如需从 NextAuth 迁移到 Better Auth,请参阅 [NextAuth 迁移指南](/zh/docs/self-hosting/advanced/auth/nextauth-to-betterauth)。 + + 在使用 NextAuth 之前,请先在 LobeChat 的环境变量中设置以下变量: | 环境变量 | 类型 | 描述 | diff --git a/docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx b/docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx new file mode 100644 index 0000000000..37df2f8063 --- /dev/null +++ b/docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx @@ -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. + + + Better Auth is the recommended authentication solution for LobeChat. It offers simpler configuration, more SSO providers, and better self-hosting support. + + + + **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) + + +## 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 | + + + **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.). + + +## 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__ID` and `AUTH__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 | + + + **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_`. + + +### 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. + + + **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. + + +### 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 + ``` + + + See [Authentication Service Configuration](/docs/self-hosting/advanced/auth) for complete environment variables and SSO provider setup. + + +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. + + + This method is quick and requires minimal setup. Users just need to re-login with their existing SSO provider. + + +## 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. + + + **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 + + +### 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 + + + 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 + ``` + + +### 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. + + + For complete Better Auth configuration, see [Authentication Service Configuration](/docs/self-hosting/advanced/auth), including all supported SSO providers and email service configuration. + + +## What Gets Migrated + +| Data | Simple Migration | Full Migration | +| -------------------------------------- | ---------------- | -------------- | +| User accounts | ✅ (via re-login) | ✅ | +| SSO connections (Google, GitHub, etc.) | ❌ | ✅ | +| Chat history | ✅ | ✅ | +| User settings | ✅ | ✅ | + + + **Note**: Sessions and verification tokens are not migrated as they are temporary data. Users will need to log in again after migration. + + +## 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 + + + + + + + + diff --git a/docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx b/docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx new file mode 100644 index 0000000000..4df0f438be --- /dev/null +++ b/docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx @@ -0,0 +1,323 @@ +--- +title: 从 NextAuth 迁移到 Better Auth +description: 将 LobeChat 部署从 NextAuth 身份验证迁移到 Better Auth 的指南,包括简单迁移和完整迁移选项。 +tags: + - 身份验证服务 + - Better Auth + - NextAuth + - 迁移 +--- + +# 从 NextAuth 迁移到 Better Auth + +本指南帮助您将现有的基于 NextAuth 的 LobeChat 部署迁移到 Better Auth。 + + + Better Auth 是 LobeChat 推荐的身份验证解决方案。它提供更简单的配置、更多的 SSO 提供商支持,以及更好的自托管体验。 + + + + **重要提醒**: + + - **务必先备份数据库**!如使用 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) 提问 + + +## 选择迁移方式 + +| 方式 | 适用场景 | 用户影响 | 数据保留 | +| ------------- | ------------- | ------- | ------------- | +| [简单迁移](#简单迁移) | 小型部署(≤ 10 用户) | 用户需重新登录 | 聊天记录、设置 | +| [完整迁移](#完整迁移) | 大型部署 | 对用户无感知 | 全部数据包括 SSO 连接 | + + + **注意**:NextAuth 从未支持邮箱密码登录,因此没有密码哈希需要迁移。完整迁移的主要好处是保留 SSO 连接(Google、GitHub 等)。 + + +## 环境变量迁移对照表 + +### 通用变量 + +| 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__ID` 和 `AUTH__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` | ⚠️ 变量名变更 | + + + **注意**:Microsoft Entra ID 的 provider 名称从 `microsoft-entra-id` 改为 `microsoft`,相应的环境变量前缀也从 `AUTH_MICROSOFT_ENTRA_ID_` 改为 `AUTH_MICROSOFT_`。 + + +### 新增功能变量 + +Better Auth 支持更多功能,以下是新增的环境变量: + +| 环境变量 | 说明 | +| ------------------------- | -------------------------------- | +| `AUTH_ALLOWED_EMAILS` | 邮箱白名单(限制注册) | +| `AUTH_EMAIL_VERIFICATION` | 启用邮箱验证(设为 `1`) | +| `ENABLE_MAGIC_LINK` | 启用魔法链接登录(设为 `1`) | +| `EMAIL_SERVICE_PROVIDER` | 邮件服务提供商(`nodemailer` 或 `resend`) | + +## 简单迁移 + +对于小型自托管部署,最简单的方法是让用户使用 SSO 账户重新登录。 + + + **限制**:此方法会丢失 SSO 连接数据。如需保留 SSO 连接,请使用 [完整迁移](#完整迁移)。 + + 虽然 SSO 连接会丢失,但用户可以在登录后通过个人资料页手动重新绑定社交账号。 + + **示例场景**:假设你之前的账户绑定了两个 SSO 账户: + + - 主邮箱(Google):`mail1@google.com` + - 副邮箱(Microsoft):`mail2@outlook.com` + + 迁移后使用 `mail2@outlook.com` 登录将会**创建新用户**,而非关联到原有账户。 + + +### 步骤 + +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 + ``` + + + 查阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth) 了解完整的环境变量和 SSO 提供商配置。 + + +2. **重新部署 LobeChat** + + 部署启用 Better Auth 的新版本。 + +3. **通知用户** + + 告知用户使用之前的 SSO 账户重新登录。由于用户 ID 保持不变,聊天记录和设置将被保留。 + + + 这种方法快速且配置简单。用户只需使用现有的 SSO 提供商重新登录即可。 + + +## 完整迁移 + +对于大型部署或需要保留 SSO 连接的情况,请使用迁移脚本。这会将数据从 `nextauth_accounts` 表迁移到 Better Auth 的 `accounts` 表。 + + + **重要说明**: + + - **务必先备份数据库**!如使用 Neon,可通过 [Fork 分支](https://neon.tech/docs/manage/branches#create-a-branch) 创建备份 + - 迁移脚本需要 **clone 仓库后在本地运行**,不是在部署环境中执行 + - 由于迁移涉及用户数据,风险较高,**官方不提供部署时自动迁移功能** + - 请务必先使用 dry-run 模式测试脚本能够顺利运行再正式执行 + - 请务必在测试环境验证后再操作生产数据库 + + +### 前置条件 + +**环境要求:** + +- 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 为最新版本 + + + 如果你长期停留在旧版本,数据库 schema 可能不是最新的。请在 clone 的仓库中运行: + + ```bash + DATABASE_URL=your-database-url pnpm db:migrate + ``` + + +### 步骤 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 环境变量并重新部署。 + + + 完整的 Better Auth 配置请参阅 [身份验证服务配置](/zh/docs/self-hosting/advanced/auth),包括所有支持的 SSO 提供商和邮件服务配置。 + + +## 迁移内容对比 + +| 数据 | 简单迁移 | 完整迁移 | +| ----------------------- | --------- | ---- | +| 用户账户 | ✅(通过重新登录) | ✅ | +| SSO 连接(Google、GitHub 等) | ❌ | ✅ | +| 聊天记录 | ✅ | ✅ | +| 用户设置 | ✅ | ✅ | + + + **注意**:Sessions 和 verification tokens 不会被迁移,因为它们是临时数据。迁移后用户需要重新登录。 + + +## 常见问题 + +### 迁移后用户无法登录 + +- 检查 `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` 更新数据库结构,然后再执行迁移脚本。 + +## 相关阅读 + + + + + + + + diff --git a/docs/self-hosting/advanced/redis.mdx b/docs/self-hosting/advanced/redis.mdx new file mode 100644 index 0000000000..6fad23869f --- /dev/null +++ b/docs/self-hosting/advanced/redis.mdx @@ -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. + + + 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.). + + +## 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 + + + ### `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` + + + If you use Redis services from cloud providers, you usually need to enable TLS to ensure secure + data transmission. + + + ### `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` + + +## 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 + + + 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. + + +- **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 diff --git a/docs/self-hosting/advanced/redis.zh-CN.mdx b/docs/self-hosting/advanced/redis.zh-CN.mdx new file mode 100644 index 0000000000..659cfdeb5d --- /dev/null +++ b/docs/self-hosting/advanced/redis.zh-CN.mdx @@ -0,0 +1,126 @@ +--- +title: 配置 Redis 缓存服务 +description: 了解如何配置 Redis 缓存服务以优化 LobeChat 的性能和会话管理。 +tags: + - Redis + - 缓存 + - 会话存储 + - 性能优化 +--- + +# 配置 Redis 缓存服务 + +LobeChat 使用 Redis 作为高性能缓存和会话存储服务,用于优化系统性能和管理用户认证状态。 + + + LobeChat 使用标准 Redis 协议(通过 ioredis 库),支持任何兼容 Redis 协议的服务,包括 Redis + 官方服务、自部署 Redis、以及云服务商提供的 Redis 服务(如 AWS ElastiCache、阿里云 Redis + 等)。 + + +## 使用场景 + +Redis 在 LobeChat 中主要用于以下场景: + +### 认证会话存储 + +作为 Better Auth 的二级存储,用于存储用户认证 session 和 token 数据。这可以实现: + +- 跨多个服务实例共享会话状态 +- 更快的会话验证速度 +- 支持会话撤销和管理 + +### 文件代理缓存 + +缓存 S3 预签名 URL,减少对 S3 API 的调用次数,优化文件访问性能。 + +### Agent 配置缓存 + +缓存 Agent 配置数据,减少数据库查询,提升响应速度。 + +## 核心环境变量 + + + ### `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` + + + 如果你使用云服务商提供的 Redis 服务,通常需要启用 TLS 以确保数据传输安全。 + + + ### `REDIS_PASSWORD` + + Redis 认证密码(可选)。如果 Redis 服务器配置了密码认证,需要设置此变量。 + + ### `REDIS_USERNAME` + + Redis 认证用户名(可选)。Redis 6.0+ 支持 ACL 用户认证,如果使用了用户名认证,需要设置此变量。 + + ### `REDIS_DATABASE` + + Redis 数据库索引(可选)。Redis 支持多个数据库(默认 0-15),可以指定使用的数据库。 + + - 默认值:`0` + - 示例:`REDIS_DATABASE=1` + + +## 配置示例 + +### 本地开发 + +```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 +``` + +## 注意事项 + + + Redis 是可选服务。如果不配置 `REDIS_URL`,LobeChat 仍然可以正常运行,但会失去上述缓存和会话管理的优化功能。 + + +- **内存管理**:Redis 是内存数据库,请确保服务器有足够的内存 +- **持久化**:建议启用 Redis 的 RDB 或 AOF 持久化,防止数据丢失 +- **高可用**:生产环境建议使用 Redis Sentinel 或 Redis Cluster 实现高可用 diff --git a/docs/self-hosting/advanced/redis/upstash.mdx b/docs/self-hosting/advanced/redis/upstash.mdx new file mode 100644 index 0000000000..5a644e9bbb --- /dev/null +++ b/docs/self-hosting/advanced/redis/upstash.mdx @@ -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 + + + ### 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: + + {'Copy + + ### 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 + ``` + + + Upstash uses `rediss://` (with double 's') for TLS connections. LobeChat supports this format automatically. + + + +## 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 + + + Upstash offers a generous free tier with 10,000 commands per day, which is sufficient for personal use and small deployments. + + + + Make sure to keep your Redis URL secure and never expose it in client-side code or public repositories. + + +- **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 diff --git a/docs/self-hosting/advanced/redis/upstash.zh-CN.mdx b/docs/self-hosting/advanced/redis/upstash.zh-CN.mdx new file mode 100644 index 0000000000..3f4b363838 --- /dev/null +++ b/docs/self-hosting/advanced/redis/upstash.zh-CN.mdx @@ -0,0 +1,69 @@ +--- +title: 配置 Upstash Redis 服务 +description: 详细指南:如何配置 Upstash Redis 用于 LobeChat 的缓存和会话存储。 +tags: + - Upstash + - Redis + - 缓存 + - 配置指南 +--- + +# 配置 Upstash Redis 服务 + +[Upstash](https://upstash.com/) 是一个 Serverless Redis 服务,提供免费额度和按量付费模式,非常适合 LobeChat 部署使用。 + +## 配置步骤 + + + ### 在 Upstash 创建 Redis 数据库 + + 1. 访问 [Upstash 控制台](https://console.upstash.com/) 并注册 + 2. 点击 **Create Database**,配置:名称、区域、启用 TLS + 3. 从数据库详情页复制 **Redis URL**(TCP 连接方式,不是 REST API): + + {'从 + + ### 配置环境变量 + + ```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 + ``` + + + Upstash 使用 `rediss://`(双 's')表示 TLS 连接,LobeChat 自动支持此格式。 + + + +## 环境变量概览 + +```shell +# Upstash Redis 连接 URL +REDIS_URL=rediss://default:xxxxxxxxxxxxx@us1-xxxxx-xxxxx.upstash.io:6379 + +# 可选:启用 TLS 加密(Upstash 推荐) +REDIS_TLS=1 + +# 可选:键前缀,用于数据隔离 +REDIS_PREFIX=lobechat +``` + +## 注意事项 + + + Upstash 提供慷慨的免费额度:每天 10,000 次命令,足够个人使用和小规模部署。 + + + + 请确保安全保管你的 Redis URL,切勿在客户端代码或公开仓库中暴露。 + + +- **免费额度限制**:每天 10,000 次命令,256MB 存储 +- **TLS 必需**:Upstash 要求 TLS 连接,确保设置 `REDIS_TLS=1` +- **Regional vs Global**:如果用户分布全球,选择 Global 可获得更低延迟 diff --git a/docs/self-hosting/environment-variables/auth.mdx b/docs/self-hosting/environment-variables/auth.mdx index 65702e320d..acab33f10d 100644 --- a/docs/self-hosting/environment-variables/auth.mdx +++ b/docs/self-hosting/environment-variables/auth.mdx @@ -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` diff --git a/docs/self-hosting/environment-variables/auth.zh-CN.mdx b/docs/self-hosting/environment-variables/auth.zh-CN.mdx index 4dddbd8820..d8c977d539 100644 --- a/docs/self-hosting/environment-variables/auth.zh-CN.mdx +++ b/docs/self-hosting/environment-variables/auth.zh-CN.mdx @@ -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` diff --git a/docs/self-hosting/environment-variables/basic.mdx b/docs/self-hosting/environment-variables/basic.mdx index c813834643..c206219a9b 100644 --- a/docs/self-hosting/environment-variables/basic.mdx +++ b/docs/self-hosting/environment-variables/basic.mdx @@ -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=` + + + This key is used to encrypt sensitive data. Once set, do not change it, otherwise encrypted data cannot be decrypted. + + + + ### `API_KEY_SELECT_MODE` - Type:Optional diff --git a/docs/self-hosting/environment-variables/basic.zh-CN.mdx b/docs/self-hosting/environment-variables/basic.zh-CN.mdx index 1fc05e7afa..d9697f9f44 100644 --- a/docs/self-hosting/environment-variables/basic.zh-CN.mdx +++ b/docs/self-hosting/environment-variables/basic.zh-CN.mdx @@ -16,6 +16,19 @@ LobeChat 在部署时提供了一些额外的配置项,你可以使用环境 ## 通用变量 +### `KEY_VAULTS_SECRET` + +- 类型:必选(服务端数据库模式) +- 描述:用于加密用户存储在数据库中的敏感信息(如 API Key、baseURL 等),防止数据库泄露时关键信息被暴露 +- 默认值:- +- 示例:`Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ=` + + + 此密钥用于加密敏感数据,一旦设置后请勿更改,否则已加密的数据将无法解密。 + + + + ### `API_KEY_SELECT_MODE` - 类型:可选 diff --git a/docs/self-hosting/environment-variables/redis.mdx b/docs/self-hosting/environment-variables/redis.mdx new file mode 100644 index 0000000000..d9568112ff --- /dev/null +++ b/docs/self-hosting/environment-variables/redis.mdx @@ -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` + + + Redis services from cloud providers usually require TLS enabled to ensure secure data + transmission. + + +### `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` diff --git a/docs/self-hosting/environment-variables/redis.zh-CN.mdx b/docs/self-hosting/environment-variables/redis.zh-CN.mdx new file mode 100644 index 0000000000..680ae6c0bf --- /dev/null +++ b/docs/self-hosting/environment-variables/redis.zh-CN.mdx @@ -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` + + + 云服务商提供的 Redis 服务通常需要启用 TLS 以确保数据传输安全。 + + +### `REDIS_PASSWORD` + +- 类型:可选 +- 描述:Redis 认证密码 +- 默认值:- +- 示例:`your-password` + +### `REDIS_USERNAME` + +- 类型:可选 +- 描述:Redis 认证用户名(Redis 6.0+ ACL 认证) +- 默认值:- +- 示例:`default` + +### `REDIS_DATABASE` + +- 类型:可选 +- 描述:Redis 数据库索引(0-15) +- 默认值:`0` +- 示例:`1` diff --git a/docs/self-hosting/migration/v2/breaking-changes.mdx b/docs/self-hosting/migration/v2/breaking-changes.mdx index 0513f41e9b..686e23b675 100644 --- a/docs/self-hosting/migration/v2/breaking-changes.mdx +++ b/docs/self-hosting/migration/v2/breaking-changes.mdx @@ -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 diff --git a/docs/self-hosting/migration/v2/breaking-changes.zh-CN.mdx b/docs/self-hosting/migration/v2/breaking-changes.zh-CN.mdx index 2d596a9b03..9243417b31 100644 --- a/docs/self-hosting/migration/v2/breaking-changes.zh-CN.mdx +++ b/docs/self-hosting/migration/v2/breaking-changes.zh-CN.mdx @@ -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)。 ## 数据库模式变更 diff --git a/docs/self-hosting/server-database/docker-compose.mdx b/docs/self-hosting/server-database/docker-compose.mdx index 0a1249b389..7bd7ee3cf5 100644 --- a/docs/self-hosting/server-database/docker-compose.mdx +++ b/docs/self-hosting/server-database/docker-compose.mdx @@ -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' diff --git a/docs/self-hosting/server-database/docker-compose.zh-CN.mdx b/docs/self-hosting/server-database/docker-compose.zh-CN.mdx index 6808e0b1af..e187c034c5 100644 --- a/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +++ b/docs/self-hosting/server-database/docker-compose.zh-CN.mdx @@ -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' diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index 394e92e06f..648316e698 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -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 diff --git a/e2e/docs/local-setup.md b/e2e/docs/local-setup.md index 980387d2e9..b9f58e11ab 100644 --- a/e2e/docs/local-setup.md +++ b/e2e/docs/local-setup.md @@ -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 变量(必需) diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index 56b819abfa..16cdc558c3 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -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 { 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 { function getServerEnv(port: number): Record { 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, diff --git a/e2e/src/support/webServer.ts b/e2e/src/support/webServer.ts index 68c2faf5f0..a91a722daf 100644 --- a/e2e/src/support/webServer.ts +++ b/e2e/src/support/webServer.ts @@ -135,13 +135,14 @@ export async function startWebServer(options: WebServerOptions): Promise { ...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 diff --git a/package.json b/package.json index a80ac56ef5..6da4cca570 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/database/src/schemas/nextauth.ts b/packages/database/src/schemas/nextauth.ts index e7012b7994..52efe589a8 100644 --- a/packages/database/src/schemas/nextauth.ts +++ b/packages/database/src/schemas/nextauth.ts @@ -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().notNull(), + type: text('type').$type().notNull(), userId: text('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), diff --git a/packages/utils/src/server/__tests__/auth.test.ts b/packages/utils/src/server/__tests__/auth.test.ts index 73b768985e..aa24647b47 100644 --- a/packages/utils/src/server/__tests__/auth.test.ts +++ b/packages/utils/src/server/__tests__/auth.test.ts @@ -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({ diff --git a/packages/utils/src/server/auth.ts b/packages/utils/src/server/auth.ts index f4fa15715c..1cf3aeee40 100644 --- a/packages/utils/src/server/auth.ts +++ b/packages/utils/src/server/auth.ts @@ -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 }; }; /** diff --git a/scripts/_shared/checkDeprecatedAuth.js b/scripts/_shared/checkDeprecatedAuth.js new file mode 100644 index 0000000000..6eedf9add1 --- /dev/null +++ b/scripts/_shared/checkDeprecatedAuth.js @@ -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 }; diff --git a/scripts/_shared/checkDeprecatedClerkEnv.js b/scripts/_shared/checkDeprecatedClerkEnv.js deleted file mode 100644 index db2967cd5b..0000000000 --- a/scripts/_shared/checkDeprecatedClerkEnv.js +++ /dev/null @@ -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 }; diff --git a/scripts/clerk-to-betterauth/index.ts b/scripts/clerk-to-betterauth/index.ts index 9722f68e02..b56b06aba6 100644 --- a/scripts/clerk-to-betterauth/index.ts +++ b/scripts/clerk-to-betterauth/index.ts @@ -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(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(); diff --git a/scripts/nextauth-to-betterauth/_internal/config.ts b/scripts/nextauth-to-betterauth/_internal/config.ts new file mode 100644 index 0000000000..63d23d535e --- /dev/null +++ b/scripts/nextauth-to-betterauth/_internal/config.ts @@ -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'; +} diff --git a/scripts/nextauth-to-betterauth/_internal/db.ts b/scripts/nextauth-to-betterauth/_internal/db.ts new file mode 100644 index 0000000000..e94469bc39 --- /dev/null +++ b/scripts/nextauth-to-betterauth/_internal/db.ts @@ -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'; diff --git a/scripts/nextauth-to-betterauth/_internal/env.ts b/scripts/nextauth-to-betterauth/_internal/env.ts new file mode 100644 index 0000000000..c9dd18097a --- /dev/null +++ b/scripts/nextauth-to-betterauth/_internal/env.ts @@ -0,0 +1,6 @@ +import { existsSync } from 'node:fs'; +import { loadEnvFile } from 'node:process'; + +if (existsSync('.env')) { + loadEnvFile(); +} diff --git a/scripts/nextauth-to-betterauth/index.ts b/scripts/nextauth-to-betterauth/index.ts new file mode 100644 index 0000000000..b696000384 --- /dev/null +++ b/scripts/nextauth-to-betterauth/index.ts @@ -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(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(); + + 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 = {}; + + 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((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(); diff --git a/scripts/nextauth-to-betterauth/verify.ts b/scripts/nextauth-to-betterauth/verify.ts new file mode 100644 index 0000000000..296fe2e99f --- /dev/null +++ b/scripts/nextauth-to-betterauth/verify.ts @@ -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>) { + 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 = {}; + const actualProviderCounts: Record = {}; + + 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) => + 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(); + }); diff --git a/scripts/prebuild.mts b/scripts/prebuild.mts index fa56f6c4a2..6517b6c73f 100644 --- a/scripts/prebuild.mts +++ b/scripts/prebuild.mts @@ -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(); // 执行删除操作 diff --git a/scripts/serverLauncher/startServer.js b/scripts/serverLauncher/startServer.js index 8a456628e6..2be35cdf1d 100644 --- a/scripts/serverLauncher/startServer.js +++ b/scripts/serverLauncher/startServer.js @@ -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('-------------------------------------'); diff --git a/src/app/(backend)/api/auth/[...all]/route.ts b/src/app/(backend)/api/auth/[...all]/route.ts index 0ec6d56e88..62788509d7 100644 --- a/src/app/(backend)/api/auth/[...all]/route.ts +++ b/src/app/(backend)/api/auth/[...all]/route.ts @@ -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); }; diff --git a/src/app/(backend)/api/auth/adapter/route.ts b/src/app/(backend)/api/auth/adapter/route.ts deleted file mode 100644 index 70d6d16364..0000000000 --- a/src/app/(backend)/api/auth/adapter/route.ts +++ /dev/null @@ -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'; diff --git a/src/app/(backend)/api/webhooks/casdoor/route.ts b/src/app/(backend)/api/webhooks/casdoor/route.ts index f44e23157b..d83c0d38db 100644 --- a/src/app/(backend)/api/webhooks/casdoor/route.ts +++ b/src/app/(backend)/api/webhooks/casdoor/route.ts @@ -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 => { 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, diff --git a/src/app/(backend)/api/webhooks/logto/route.ts b/src/app/(backend)/api/webhooks/logto/route.ts index 7a3e6b8b8b..2d279203a1 100644 --- a/src/app/(backend)/api/webhooks/logto/route.ts +++ b/src/app/(backend)/api/webhooks/logto/route.ts @@ -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 => { 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 => { } 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 }); diff --git a/src/app/(backend)/middleware/auth/index.test.ts b/src/app/(backend)/middleware/auth/index.test.ts index 902de75e55..ce8820239f 100644 --- a/src/app/(backend)/middleware/auth/index.test.ts +++ b/src/app/(backend)/middleware/auth/index.test.ts @@ -24,10 +24,17 @@ vi.mock('@/envs/auth', async (importOriginal) => { const actual = await importOriginal(); 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'); diff --git a/src/app/(backend)/middleware/auth/index.ts b/src/app/(backend)/middleware/auth/index.ts index 67324ef86c..0c0fc43a7d 100644 --- a/src/app/(backend)/middleware/auth/index.ts +++ b/src/app/(backend)/middleware/auth/index.ts @@ -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); diff --git a/src/app/(backend)/middleware/auth/utils.test.ts b/src/app/(backend)/middleware/auth/utils.test.ts index 73d3e901e9..bd2c89e8c0 100644 --- a/src/app/(backend)/middleware/auth/utils.test.ts +++ b/src/app/(backend)/middleware/auth/utils.test.ts @@ -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', () => { diff --git a/src/app/(backend)/middleware/auth/utils.ts b/src/app/(backend)/middleware/auth/utils.ts index 5de4077aa1..b599a0c189 100644 --- a/src/app/(backend)/middleware/auth/utils.ts +++ b/src/app/(backend)/middleware/auth/utils.ts @@ -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; diff --git a/src/app/(backend)/webapi/chat/[provider]/route.test.ts b/src/app/(backend)/webapi/chat/[provider]/route.test.ts index c1d840bb35..6ea9784be4 100644 --- a/src/app/(backend)/webapi/chat/[provider]/route.test.ts +++ b/src/app/(backend)/webapi/chat/[provider]/route.test.ts @@ -27,10 +27,17 @@ vi.mock('@/envs/auth', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - enableBetterAuth: false, }; }); +vi.mock('@/auth', () => ({ + auth: { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + }, +})); + // 模拟请求和响应 let request: Request; beforeEach(() => { diff --git a/src/app/(backend)/webapi/create-image/comfyui/route.ts b/src/app/(backend)/webapi/create-image/comfyui/route.ts index 698a445144..fbde42c389 100644 --- a/src/app/(backend)/webapi/create-image/comfyui/route.ts +++ b/src/app/(backend)/webapi/create-image/comfyui/route.ts @@ -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 }); diff --git a/src/app/(backend)/webapi/models/[provider]/route.test.ts b/src/app/(backend)/webapi/models/[provider]/route.test.ts index 15cda7f250..cd974d947f 100644 --- a/src/app/(backend)/webapi/models/[provider]/route.test.ts +++ b/src/app/(backend)/webapi/models/[provider]/route.test.ts @@ -21,10 +21,17 @@ vi.mock('@/envs/auth', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - enableBetterAuth: false, }; }); +vi.mock('@/auth', () => ({ + auth: { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + }, +})); + vi.mock('@/server/modules/ModelRuntime', () => ({ initModelRuntimeFromDB: vi.fn(), })); diff --git a/src/app/[variants]/(auth)/next-auth/error/AuthErrorPage.tsx b/src/app/[variants]/(auth)/next-auth/error/AuthErrorPage.tsx deleted file mode 100644 index 569778448d..0000000000 --- a/src/app/[variants]/(auth)/next-auth/error/AuthErrorPage.tsx +++ /dev/null @@ -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 ; -}); diff --git a/src/app/[variants]/(auth)/next-auth/error/page.tsx b/src/app/[variants]/(auth)/next-auth/error/page.tsx deleted file mode 100644 index 531817481e..0000000000 --- a/src/app/[variants]/(auth)/next-auth/error/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Suspense } from 'react'; - -import Loading from '@/components/Loading/BrandTextLoading'; - -import AuthErrorPage from './AuthErrorPage'; - -export default () => ( - }> - - -); diff --git a/src/app/[variants]/(auth)/next-auth/signin/AuthSignInBox.tsx b/src/app/[variants]/(auth)/next-auth/signin/AuthSignInBox.tsx deleted file mode 100644 index 216e8de877..0000000000 --- a/src/app/[variants]/(auth)/next-auth/signin/AuthSignInBox.tsx +++ /dev/null @@ -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 ( - - - - - - ); -}); - -/** - * 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(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 ( -
-
- {/* Card Body */} - - {/* Header */} -
- -
- -
- {t('signin.title')} -
- - {t('signin.subtitle', { appName: BRANDING_NAME })} - -
- {/* Content */} - - {oAuthSSOProviders ? ( - oAuthSSOProviders.map((provider) => ( - - )) - ) : ( - - )} - -
-
-
- {/* Footer */} - - - - - - - - - {footerBtns.map((btn) => ( - - ))} - - - -
-
- ); -}); diff --git a/src/app/[variants]/(auth)/next-auth/signin/page.tsx b/src/app/[variants]/(auth)/next-auth/signin/page.tsx deleted file mode 100644 index bc7e7a6e5d..0000000000 --- a/src/app/[variants]/(auth)/next-auth/signin/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Suspense } from 'react'; - -import Loading from '@/components/Loading/BrandTextLoading'; - -import AuthSignInBox from './AuthSignInBox'; - -export default () => ( - }> - - -); diff --git a/src/app/[variants]/(auth)/reset-password/layout.tsx b/src/app/[variants]/(auth)/reset-password/layout.tsx deleted file mode 100644 index 5f388fe6ab..0000000000 --- a/src/app/[variants]/(auth)/reset-password/layout.tsx +++ /dev/null @@ -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; diff --git a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx b/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx index db199731e9..9f686994ce 100644 --- a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx +++ b/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx @@ -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'; diff --git a/src/app/[variants]/(auth)/signin/layout.tsx b/src/app/[variants]/(auth)/signin/layout.tsx deleted file mode 100644 index 5f388fe6ab..0000000000 --- a/src/app/[variants]/(auth)/signin/layout.tsx +++ /dev/null @@ -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; diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx b/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx index 6b44bd0b85..19e987f4a9 100644 --- a/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +++ b/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx @@ -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 ; - } - - return notFound(); + return ; }; export default Page; diff --git a/src/app/[variants]/(auth)/verify-email/layout.tsx b/src/app/[variants]/(auth)/verify-email/layout.tsx deleted file mode 100644 index 5f388fe6ab..0000000000 --- a/src/app/[variants]/(auth)/verify-email/layout.tsx +++ /dev/null @@ -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; diff --git a/src/app/[variants]/(main)/settings/profile/features/SSOProvidersList/index.tsx b/src/app/[variants]/(main)/settings/profile/features/SSOProvidersList/index.tsx index 00a3cc5b7b..fdb46f47cb 100644 --- a/src/app/[variants]/(main)/settings/profile/features/SSOProvidersList/index.tsx +++ b/src/app/[variants]/(main)/settings/profile/features/SSOProvidersList/index.tsx @@ -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: {t('profile.sso.unlink.title', { provider })}, @@ -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(() => { )} - {!(isDesktop && isLoginWithBetterAuth) && ( + {!isDesktop && ( handleUnlinkSSO(item.provider, item.providerAccountId)} + onClick={() => handleUnlinkSSO(item.provider)} size={'small'} /> )} ))} - {/* 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 && ( diff --git a/src/app/[variants]/(main)/settings/profile/index.tsx b/src/app/[variants]/(main)/settings/profile/index.tsx index e33b16aeb4..48c9995ecb 100644 --- a/src/app/[variants]/(main)/settings/profile/index.tsx +++ b/src/app/[variants]/(main)/settings/profile/index.tsx @@ -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 */} - {/* 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 && ( <> @@ -127,7 +121,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => { )} {/* Email Row - Read Only */} - {isLoginWithAuth && userProfile?.email && ( + {isLogin && userProfile?.email && ( <> @@ -137,7 +131,7 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => { )} {/* SSO Providers Row */} - {isLoginWithAuth && ( + {isLogin && ( <> diff --git a/src/components/NextAuth/AuthIcons.tsx b/src/components/AuthIcons.tsx similarity index 70% rename from src/components/NextAuth/AuthIcons.tsx rename to src/components/AuthIcons.tsx index e392f7c97a..d5da3f17ae 100644 --- a/src/components/NextAuth/AuthIcons.tsx +++ b/src/components/AuthIcons.tsx @@ -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 ; + const IconComponent = iconComponents[id]; + if (IconComponent) { + return ; + } + // Fallback to generic user icon for unknown providers + return ; }; export default AuthIcons; diff --git a/src/envs/auth.test.ts b/src/envs/auth.test.ts deleted file mode 100644 index f4e57eaf88..0000000000 --- a/src/envs/auth.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/envs/auth.ts b/src/envs/auth.ts index 04b019ffaa..2ba415421f 100644 --- a/src/envs/auth.ts +++ b/src/envs/auth.ts @@ -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'; diff --git a/src/envs/email.ts b/src/envs/email.ts index cb68a135cd..01b5cc8b4e 100644 --- a/src/envs/email.ts +++ b/src/envs/email.ts @@ -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', diff --git a/src/envs/redis.ts b/src/envs/redis.ts index 898ddb7fba..5096afc7fe 100644 --- a/src/envs/redis.ts +++ b/src/envs/redis.ts @@ -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, }; }; diff --git a/src/features/User/__tests__/PanelContent.test.tsx b/src/features/User/__tests__/PanelContent.test.tsx index 673cec3ae1..2ae8d38588 100644 --- a/src/features/User/__tests__/PanelContent.test.tsx +++ b/src/features/User/__tests__/PanelContent.test.tsx @@ -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(); diff --git a/src/features/User/__tests__/UserAvatar.test.tsx b/src/features/User/__tests__/UserAvatar.test.tsx index b37e859238..afd6d5af12 100644 --- a/src/features/User/__tests__/UserAvatar.test.tsx +++ b/src/features/User/__tests__/UserAvatar.test.tsx @@ -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'; diff --git a/src/layout/AuthProvider/NextAuth/UserUpdater.tsx b/src/layout/AuthProvider/NextAuth/UserUpdater.tsx deleted file mode 100644 index 98250649ef..0000000000 --- a/src/layout/AuthProvider/NextAuth/UserUpdater.tsx +++ /dev/null @@ -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; diff --git a/src/layout/AuthProvider/NextAuth/index.tsx b/src/layout/AuthProvider/NextAuth/index.tsx deleted file mode 100644 index d98a8d8295..0000000000 --- a/src/layout/AuthProvider/NextAuth/index.tsx +++ /dev/null @@ -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 ( - - {children} - - - ); -}; - -export default NextAuth; diff --git a/src/layout/AuthProvider/index.tsx b/src/layout/AuthProvider/index.tsx index e2e1f92c2a..b2c25bef2b 100644 --- a/src/layout/AuthProvider/index.tsx +++ b/src/layout/AuthProvider/index.tsx @@ -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 {children}; } - if (authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH) { + if (authEnv.AUTH_SECRET) { return {children}; } - if (authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH) { - return {children}; - } - return {children}; }; diff --git a/src/layout/GlobalProvider/StoreInitialization.tsx b/src/layout/GlobalProvider/StoreInitialization.tsx index 9c2bcec03e..19229b8113 100644 --- a/src/layout/GlobalProvider/StoreInitialization.tsx +++ b/src/layout/GlobalProvider/StoreInitialization.tsx @@ -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 }); diff --git a/src/libs/better-auth/define-config.ts b/src/libs/better-auth/define-config.ts index fe3b3c46ef..5b51930376 100644 --- a/src/libs/better-auth/define-config.ts +++ b/src/libs/better-auth/define-config.ts @@ -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(), diff --git a/src/libs/better-auth/plugins/email-whitelist.test.ts b/src/libs/better-auth/plugins/email-whitelist.test.ts new file mode 100644 index 0000000000..3200c9b600 --- /dev/null +++ b/src/libs/better-auth/plugins/email-whitelist.test.ts @@ -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); + }); + }); +}); diff --git a/src/libs/better-auth/plugins/email-whitelist.ts b/src/libs/better-auth/plugins/email-whitelist.ts new file mode 100644 index 0000000000..05eeaf83f9 --- /dev/null +++ b/src/libs/better-auth/plugins/email-whitelist.ts @@ -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 }; + }, + }, + }, + }, + }, + }; + }, +}); diff --git a/src/libs/next-auth/adapter/index.ts b/src/libs/next-auth/adapter/index.ts deleted file mode 100644 index c9e5c384c9..0000000000 --- a/src/libs/next-auth/adapter/index.ts +++ /dev/null @@ -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 { - const data = await fetcher('createAuthenticator', authenticator); - return await postProcessor(data); - }, - async createSession(session): Promise { - const data = await fetcher('createSession', session); - return await postProcessor(data); - }, - async createUser(user): Promise { - const data = await fetcher('createUser', user); - return await postProcessor(data); - }, - async createVerificationToken(data): Promise { - const result = await fetcher('createVerificationToken', data); - return await postProcessor(result); - }, - async deleteSession(sessionToken): Promise { - const result = await fetcher('deleteSession', sessionToken); - await postProcessor(result); - return; - }, - async deleteUser(id): Promise { - const result = await fetcher('deleteUser', id); - await postProcessor(result); - return; - }, - - async getAccount(providerAccountId, provider): Promise { - const data = await fetcher('getAccount', { - provider, - providerAccountId, - }); - return await postProcessor(data); - }, - - async getAuthenticator(credentialID): Promise { - 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 { - log('getUser called with id:', id); - const result = await fetcher('getUser', id); - return await postProcessor(result); - }, - - async getUserByAccount(account): Promise { - const data = await fetcher('getUserByAccount', account); - return await postProcessor(data); - }, - - async getUserByEmail(email): Promise { - const data = await fetcher('getUserByEmail', email); - return await postProcessor(data); - }, - - async linkAccount(data): Promise { - const result = await fetcher('linkAccount', data); - return await postProcessor(result); - }, - - async listAuthenticatorsByUserId(userId): Promise { - const result = await fetcher('listAuthenticatorsByUserId', userId); - return await postProcessor(result); - }, - - // @ts-ignore: The return type is {Promise | Awaitable} - async unlinkAccount(account): Promise { - const result = await fetcher('unlinkAccount', account); - await postProcessor(result); - return; - }, - - async updateAuthenticatorCounter(credentialID, counter): Promise { - const result = await fetcher('updateAuthenticatorCounter', { - counter, - credentialID, - }); - return await postProcessor(result); - }, - - async updateSession(data): Promise { - const result = await fetcher('updateSession', data); - return await postProcessor(result); - }, - - async updateUser(user): Promise { - const result = await fetcher('updateUser', user); - return await postProcessor(result); - }, - - async useVerificationToken(identifier_token): Promise { - const result = await fetcher('useVerificationToken', identifier_token); - return await postProcessor(result); - }, - }; -} diff --git a/src/libs/next-auth/auth.config.ts b/src/libs/next-auth/auth.config.ts deleted file mode 100644 index 19c26605f9..0000000000 --- a/src/libs/next-auth/auth.config.ts +++ /dev/null @@ -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; diff --git a/src/libs/next-auth/index.ts b/src/libs/next-auth/index.ts deleted file mode 100644 index 70c2490c1f..0000000000 --- a/src/libs/next-auth/index.ts +++ /dev/null @@ -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); diff --git a/src/libs/next-auth/sso-providers/auth0.ts b/src/libs/next-auth/sso-providers/auth0.ts deleted file mode 100644 index e9daec4ca6..0000000000 --- a/src/libs/next-auth/sso-providers/auth0.ts +++ /dev/null @@ -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; diff --git a/src/libs/next-auth/sso-providers/authelia.ts b/src/libs/next-auth/sso-providers/authelia.ts deleted file mode 100644 index 892b1f1b7c..0000000000 --- a/src/libs/next-auth/sso-providers/authelia.ts +++ /dev/null @@ -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, -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/authentik.ts b/src/libs/next-auth/sso-providers/authentik.ts deleted file mode 100644 index 508ff2ccd8..0000000000 --- a/src/libs/next-auth/sso-providers/authentik.ts +++ /dev/null @@ -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; diff --git a/src/libs/next-auth/sso-providers/casdoor.ts b/src/libs/next-auth/sso-providers/casdoor.ts deleted file mode 100644 index dded67cafc..0000000000 --- a/src/libs/next-auth/sso-providers/casdoor.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type OIDCConfig, type OIDCUserConfig } from '@auth/core/providers'; - -import { CommonProviderConfig } from './sso.config'; - -interface CasdoorProfile extends Record { - avatar: string; - displayName: string; - email: string; - emailVerified: boolean; - firstName: string; - id: string; - lastName: string; - name: string; - owner: string; - permanentAvatar: string; -} - -function LobeCasdoorProvider(config: OIDCUserConfig): OIDCConfig { - 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; diff --git a/src/libs/next-auth/sso-providers/cloudflare-zero-trust.ts b/src/libs/next-auth/sso-providers/cloudflare-zero-trust.ts deleted file mode 100644 index 4635f9f145..0000000000 --- a/src/libs/next-auth/sso-providers/cloudflare-zero-trust.ts +++ /dev/null @@ -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, -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/cognito.ts b/src/libs/next-auth/sso-providers/cognito.ts deleted file mode 100644 index b7131976d8..0000000000 --- a/src/libs/next-auth/sso-providers/cognito.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Cognito from 'next-auth/providers/cognito'; - -const provider = { - id: 'cognito', - provider: Cognito({}), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/feishu.ts b/src/libs/next-auth/sso-providers/feishu.ts deleted file mode 100644 index d28b4a4510..0000000000 --- a/src/libs/next-auth/sso-providers/feishu.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { customFetch } from 'next-auth'; -import type { OAuthConfig } from 'next-auth/providers'; - -interface FeishuProfile { - avatar_big: string; - avatar_middle: string; - avatar_thumb: string; - avatar_url: string; - en_name: string; - name: string; - open_id: string; - tenant_key: string; - union_id: string; -} - -interface FeishuProfileResponse { - data: FeishuProfile; -} - -function Feishu(): OAuthConfig { - return { - authorization: { - params: { - scope: '', - }, - url: 'https://accounts.feishu.cn/open-apis/authen/v1/authorize', - }, - checks: ['state'], - client: { - token_endpoint_auth_method: 'client_secret_post', - }, - clientId: process.env.AUTH_FEISHU_APP_ID, - clientSecret: process.env.AUTH_FEISHU_APP_SECRET, - [customFetch]: (url, options = {}) => { - if ( - url === 'https://open.feishu.cn/open-apis/authen/v2/oauth/token' && - options.method === 'POST' - ) { - if (options?.headers) { - options.headers = { - ...options.headers, - 'content-type': 'application/json; charset=utf-8', - }; - } else { - options.headers = { - 'content-type': 'application/json; charset=utf-8', - }; - } - - if (options.body instanceof URLSearchParams) { - options.body = JSON.stringify(Object.fromEntries(options.body)); - } - } - - return fetch(url, options); - }, - id: 'feishu', - name: 'Feishu', - profile(profileResponse) { - const profile = profileResponse.data; - - return { - id: profile.union_id, - image: profile.avatar_url, - name: profile.name, - providerAccountId: profile.union_id, - }; - }, - style: { - logo: 'https://p1-hera.feishucdn.com/tos-cn-i-jbbdkfciu3/268ec674a56a4510889f7f5ca14f1ba1~tplv-jbbdkfciu3-image:0:0.image', - }, - token: 'https://open.feishu.cn/open-apis/authen/v2/oauth/token', - type: 'oauth', - userinfo: 'https://open.feishu.cn/open-apis/authen/v1/user_info', - }; -} - -const provider = { - id: 'feishu', - provider: Feishu(), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/generic-oidc.ts b/src/libs/next-auth/sso-providers/generic-oidc.ts deleted file mode 100644 index 1be3cdee9b..0000000000 --- a/src/libs/next-auth/sso-providers/generic-oidc.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { OIDCConfig } from '@auth/core/providers'; - -import { CommonProviderConfig } from './sso.config'; - -export type GenericOIDCProfile = { - email: string; - id?: string; - name?: string; - picture?: string; - sub: string; - username?: string; -}; - -const provider = { - id: 'generic-oidc', - provider: { - ...CommonProviderConfig, - authorization: { params: { scope: 'email openid profile' } }, - checks: ['state', 'pkce'], - clientId: process.env.AUTH_GENERIC_OIDC_ID, - clientSecret: process.env.AUTH_GENERIC_OIDC_SECRET, - id: 'generic-oidc', - issuer: process.env.AUTH_GENERIC_OIDC_ISSUER, - name: 'Generic OIDC', - profile(profile) { - return { - email: profile.email, - id: profile.sub, - image: profile.picture, - name: profile.name ?? profile.username ?? profile.email, - providerAccountId: profile.sub, - }; - }, - type: 'oidc', - } satisfies OIDCConfig, -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/github.ts b/src/libs/next-auth/sso-providers/github.ts deleted file mode 100644 index 124e1a1e6e..0000000000 --- a/src/libs/next-auth/sso-providers/github.ts +++ /dev/null @@ -1,23 +0,0 @@ -import GitHub from 'next-auth/providers/github'; - -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'github', - provider: GitHub({ - ...CommonProviderConfig, - // Specify auth scope, at least include 'openid email' - authorization: { params: { scope: 'read:user user:email' } }, - profile: (profile) => { - return { - email: profile.email, - id: profile.id.toString(), - image: profile.avatar_url, - name: profile.name, - providerAccountId: profile.id.toString(), - }; - }, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/google.ts b/src/libs/next-auth/sso-providers/google.ts deleted file mode 100644 index 883424525a..0000000000 --- a/src/libs/next-auth/sso-providers/google.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Google from 'next-auth/providers/google'; - -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'google', - provider: Google({ - ...CommonProviderConfig, - authorization: { - params: { - scope: - 'openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid', - }, - }, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/index.ts b/src/libs/next-auth/sso-providers/index.ts deleted file mode 100644 index 694532b41a..0000000000 --- a/src/libs/next-auth/sso-providers/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Auth0 from './auth0'; -import Authelia from './authelia'; -import Authentik from './authentik'; -import Casdoor from './casdoor'; -import CloudflareZeroTrust from './cloudflare-zero-trust'; -import Cognito from './cognito'; -import Feishu from './feishu'; -import GenericOIDC from './generic-oidc'; -import Github from './github'; -import Google from './google'; -import Keycloak from './keycloak'; -import Logto from './logto'; -import MicrosoftEntraID from './microsoft-entra-id'; -import Okta from './okta'; -import WeChat from './wechat'; -import Zitadel from './zitadel'; - -export const ssoProviders = [ - Auth0, - Authentik, - GenericOIDC, - Github, - Zitadel, - Authelia, - Logto, - CloudflareZeroTrust, - Casdoor, - MicrosoftEntraID, - WeChat, - Keycloak, - Google, - Cognito, - Okta, - Feishu, -]; diff --git a/src/libs/next-auth/sso-providers/keycloak.ts b/src/libs/next-auth/sso-providers/keycloak.ts deleted file mode 100644 index 1e52c3d720..0000000000 --- a/src/libs/next-auth/sso-providers/keycloak.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Keycloak from 'next-auth/providers/keycloak'; - -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'keycloak', - provider: Keycloak({ - ...CommonProviderConfig, - // Specify auth scope, at least include 'openid email' - authorization: { params: { scope: 'openid email profile' } }, - profile(profile) { - return { - email: profile.email, - id: profile.sub, - name: profile.name, - providerAccountId: profile.sub, - }; - }, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/logto.ts b/src/libs/next-auth/sso-providers/logto.ts deleted file mode 100644 index 5729f8bd61..0000000000 --- a/src/libs/next-auth/sso-providers/logto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type OIDCConfig, type OIDCUserConfig } from '@auth/core/providers'; - -import { CommonProviderConfig } from './sso.config'; - -interface LogtoProfile extends Record { - email: string; - id: string; - name?: string; - picture: string; - sub: string; - username: string; -} - -function LobeLogtoProvider(config: OIDCUserConfig): OIDCConfig { - return { - ...CommonProviderConfig, - ...config, - id: 'logto', - name: 'Logto', - profile(profile) { - // You can customize the user profile mapping here - return { - email: profile.email, - id: profile.sub, - image: profile.picture, - name: profile.name ?? profile.username ?? profile.email, - providerAccountId: profile.sub, - }; - }, - type: 'oidc', - }; -} - -const provider = { - id: 'logto', - provider: LobeLogtoProvider({ - authorization: { - params: { scope: 'openid offline_access profile email' }, - }, - // You can get the issuer value from the Logto Application Details page, - // in the field "Issuer endpoint" - clientId: process.env.AUTH_LOGTO_ID, - clientSecret: process.env.AUTH_LOGTO_SECRET, - issuer: process.env.AUTH_LOGTO_ISSUER, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/microsoft-entra-id-helper.ts b/src/libs/next-auth/sso-providers/microsoft-entra-id-helper.ts deleted file mode 100644 index 8edd140270..0000000000 --- a/src/libs/next-auth/sso-providers/microsoft-entra-id-helper.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { authEnv } from '@/envs/auth'; - -function getTenantId() { - return ( - process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID ?? - process.env.AUTH_AZURE_AD_TENANT_ID ?? - authEnv.AZURE_AD_TENANT_ID - ); -} - -function getClientLoginBaseUrl() { - return process.env.AUTH_MICROSOFT_ENTRA_ID_BASE_URL ?? 'https://login.microsoftonline.com'; -} - -function getIssuer() { - const issuer = process.env.MICROSOFT_ENTRA_ID_ISSUER; - if (issuer) { - return issuer; - } - const tenantId = getTenantId(); - if (tenantId) { - // refs: https://github.com/nextauthjs/next-auth/discussions/9154#discussioncomment-10583104 - return `${getClientLoginBaseUrl()}/${tenantId}/v2.0`; - } else { - return undefined; - } -} - -export { getIssuer as getMicrosoftEntraIdIssuer, getTenantId as getMicrosoftEntraIdTenantId }; diff --git a/src/libs/next-auth/sso-providers/microsoft-entra-id.ts b/src/libs/next-auth/sso-providers/microsoft-entra-id.ts deleted file mode 100644 index 7ba821a757..0000000000 --- a/src/libs/next-auth/sso-providers/microsoft-entra-id.ts +++ /dev/null @@ -1,19 +0,0 @@ -import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id'; - -import { getMicrosoftEntraIdIssuer } from './microsoft-entra-id-helper'; -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'microsoft-entra-id', - provider: MicrosoftEntraID({ - ...CommonProviderConfig, - // Specify auth scope, at least include 'openid email' - // all scopes in Azure AD ref: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#openid-connect-scopes - authorization: { params: { scope: 'openid email profile' } }, - clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID ?? process.env.AUTH_AZURE_AD_ID, - clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET ?? process.env.AUTH_AZURE_AD_SECRET, - issuer: getMicrosoftEntraIdIssuer(), - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/okta.ts b/src/libs/next-auth/sso-providers/okta.ts deleted file mode 100644 index 2050f4c20c..0000000000 --- a/src/libs/next-auth/sso-providers/okta.ts +++ /dev/null @@ -1,22 +0,0 @@ -import Okta from 'next-auth/providers/okta'; - -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'okta', - provider: Okta({ - ...CommonProviderConfig, - authorization: { params: { scope: 'openid email profile' } }, - profile(profile) { - return { - email: profile.email, - id: profile.sub, - image: profile.picture, - name: profile.name ?? profile.preferred_username, - providerAccountId: profile.sub, - }; - }, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/sso.config.ts b/src/libs/next-auth/sso-providers/sso.config.ts deleted file mode 100644 index b31c5760d1..0000000000 --- a/src/libs/next-auth/sso-providers/sso.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type OAuth2Config } from '@auth/core/providers'; -import type { Profile } from 'next-auth'; - -export const CommonProviderConfig = { - // Auth.js does not allow email account linking by default cause it's dangerous - // ref: https://authjs.dev/reference/core/providers#allowdangerousemailaccountlinking - allowDangerousEmailAccountLinking: true, -} satisfies Partial>; diff --git a/src/libs/next-auth/sso-providers/wechat.ts b/src/libs/next-auth/sso-providers/wechat.ts deleted file mode 100644 index 87781cfc3f..0000000000 --- a/src/libs/next-auth/sso-providers/wechat.ts +++ /dev/null @@ -1,36 +0,0 @@ -import WeChat, { type WeChatProfile } from '@auth/core/providers/wechat'; - -import { CommonProviderConfig } from './sso.config'; - -const provider = { - id: 'wechat', - provider: WeChat({ - ...CommonProviderConfig, - clientId: process.env.AUTH_WECHAT_ID, - clientSecret: process.env.AUTH_WECHAT_SECRET, - platformType: 'WebsiteApp', - profile: (profile: WeChatProfile) => { - return { - email: null, - id: profile.unionid, - image: profile.headimgurl, - name: profile.nickname, - providerAccountId: profile.unionid, - }; - }, - style: { bg: '#fff', logo: 'https://authjs.dev/img/providers/wechat.svg', text: '#000' }, - token: { - async conform(response: Response) { - const data = await response.json(); - console.log('wechat data:', data); - return new Response(JSON.stringify({ ...data, token_type: 'bearer' }), { - headers: { 'Content-Type': 'application/json' }, - }); - }, - params: { appid: process.env.AUTH_WECHAT_ID, secret: process.env.AUTH_WECHAT_SECRET }, - url: 'https://api.weixin.qq.com/sns/oauth2/access_token', - }, - }), -}; - -export default provider; diff --git a/src/libs/next-auth/sso-providers/zitadel.ts b/src/libs/next-auth/sso-providers/zitadel.ts deleted file mode 100644 index ffdb399144..0000000000 --- a/src/libs/next-auth/sso-providers/zitadel.ts +++ /dev/null @@ -1,21 +0,0 @@ -import Zitadel from 'next-auth/providers/zitadel'; - -const provider = { - id: 'zitadel', - provider: Zitadel({ - // Available scopes in ZITADEL: https://zitadel.com/docs/apis/openidoauth/scopes - 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; diff --git a/src/libs/next/config/define-config.ts b/src/libs/next/config/define-config.ts index fe55aea5ac..220d48e287 100644 --- a/src/libs/next/config/define-config.ts +++ b/src/libs/next/config/define-config.ts @@ -31,10 +31,22 @@ export function defineConfig(config: CustomNextConfig) { outputFileTracingIncludes: { '*': ['public/**/*', '.next/static/**/*'] }, }; + // Vercel serverless optimization: exclude musl binaries + // Vercel uses Amazon Linux (glibc), not Alpine Linux (musl) + // This saves ~45MB (29MB canvas-musl + 16MB sharp-musl) + const vercelConfig: NextConfig = { + outputFileTracingExcludes: { + '*': [ + 'node_modules/.pnpm/@napi-rs+canvas-*-musl*', + 'node_modules/.pnpm/@img+sharp-libvips-*musl*', + ], + }, + }; + const assetPrefix = process.env.NEXT_PUBLIC_ASSET_PREFIX; const nextConfig: NextConfig = { - ...(isStandaloneMode ? standaloneConfig : {}), + ...(isStandaloneMode ? standaloneConfig : vercelConfig), assetPrefix, compiler: { diff --git a/src/libs/next/proxy/define-config.ts b/src/libs/next/proxy/define-config.ts index 3269ca1f01..696554ea9f 100644 --- a/src/libs/next/proxy/define-config.ts +++ b/src/libs/next/proxy/define-config.ts @@ -7,8 +7,7 @@ import { auth } from '@/auth'; import { LOBE_LOCALE_COOKIE } from '@/const/locale'; import { isDesktop } from '@/const/version'; import { appEnv } from '@/envs/app'; -import { OAUTH_AUTHORIZED, authEnv } from '@/envs/auth'; -import NextAuth from '@/libs/next-auth'; +import { authEnv } from '@/envs/auth'; import { type Locales } from '@/locales/resources'; import { parseBrowserLanguage } from '@/utils/locale'; import { RouteVariants } from '@/utils/server/routeVariants'; @@ -17,12 +16,8 @@ import { createRouteMatcher } from './createRouteMatcher'; // Create debug logger instances const logDefault = debug('middleware:default'); -const logNextAuth = debug('middleware:next-auth'); const logBetterAuth = debug('middleware:better-auth'); -// OIDC session pre-sync constant -const OIDC_SESSION_HEADER = 'x-oidc-session-sync'; - export function defineConfig() { const backendApiEndpoints = ['/api', '/trpc', '/webapi', '/oidc']; @@ -169,8 +164,6 @@ export function defineConfig() { '/api/agent(.*)', '/webapi(.*)', '/trpc(.*)', - // next auth - '/next-auth/(.*)', // better auth '/signin', '/signup', @@ -187,70 +180,6 @@ export function defineConfig() { '/share(.*)', ]); - const isProtectedRoute = createRouteMatcher([ - '/settings(.*)', - '/knowledge(.*)', - '/onboard(.*)', - '/oauth(.*)', - // ↓ cloud ↓ - ]); - - // Initialize an Edge compatible NextAuth middleware - const nextAuthMiddleware = NextAuth.auth((req) => { - logNextAuth('NextAuth middleware processing request: %s %s', req.method, req.url); - - const response = defaultMiddleware(req); - - // when enable auth protection, only public route is not protected, others are all protected - const isProtected = appEnv.ENABLE_AUTH_PROTECTION ? !isPublicRoute(req) : isProtectedRoute(req); - - logNextAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public'); - - // Just check if session exists - const session = req.auth; - - // Check if next-auth throws errors - // refs: https://github.com/lobehub/lobe-chat/pull/1323 - const isLoggedIn = !!session?.expires; - - logNextAuth('NextAuth session status: %O', { - expires: session?.expires, - isLoggedIn, - userId: session?.user?.id, - }); - - // Remove & amend OAuth authorized header - response.headers.delete(OAUTH_AUTHORIZED); - if (isLoggedIn) { - logNextAuth('Setting auth header: %s = %s', OAUTH_AUTHORIZED, 'true'); - response.headers.set(OAUTH_AUTHORIZED, 'true'); - - // If OIDC is enabled and user is logged in, add OIDC session pre-sync header - if (authEnv.ENABLE_OIDC && session?.user?.id) { - logNextAuth('OIDC session pre-sync: Setting %s = %s', OIDC_SESSION_HEADER, session.user.id); - response.headers.set(OIDC_SESSION_HEADER, session.user.id); - } - } else { - // If request a protected route, redirect to sign-in page - // ref: https://authjs.dev/getting-started/session-management/protecting - if (isProtected) { - logNextAuth('Request a protected route, redirecting to sign-in page'); - const callbackUrl = `${appEnv.APP_URL}${req.nextUrl.pathname}${req.nextUrl.search}`; - const nextLoginUrl = new URL('/next-auth/signin', appEnv.APP_URL); - nextLoginUrl.searchParams.set('callbackUrl', callbackUrl); - const hl = req.nextUrl.searchParams.get('hl'); - if (hl) { - nextLoginUrl.searchParams.set('hl', hl); - logNextAuth('Preserving locale to sign-in: hl=%s', hl); - } - return Response.redirect(nextLoginUrl); - } - logNextAuth('Request a free route but not login, allow visit without auth header'); - } - - return response; - }); - const betterAuthMiddleware = async (req: NextRequest) => { logBetterAuth('BetterAuth middleware processing request: %s %s', req.method, req.url); @@ -298,12 +227,10 @@ export function defineConfig() { logDefault('Middleware configuration: %O', { enableAuthProtection: appEnv.ENABLE_AUTH_PROTECTION, - enableBetterAuth: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH, - enableNextAuth: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH, enableOIDC: authEnv.ENABLE_OIDC, }); return { - middleware: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH ? nextAuthMiddleware : betterAuthMiddleware, + middleware: betterAuthMiddleware, }; } diff --git a/src/libs/oidc-provider/provider.test.ts b/src/libs/oidc-provider/provider.test.ts index a369a9b31a..5eb5077430 100644 --- a/src/libs/oidc-provider/provider.test.ts +++ b/src/libs/oidc-provider/provider.test.ts @@ -4,10 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies -vi.mock('@/envs/auth', () => ({ - enableBetterAuth: false, - enableNextAuth: false, -})); vi.mock('@/envs/app', () => ({ appEnv: { diff --git a/src/libs/redis/index.ts b/src/libs/redis/index.ts index dd7135b591..178e6382db 100644 --- a/src/libs/redis/index.ts +++ b/src/libs/redis/index.ts @@ -2,5 +2,4 @@ export * from './keys'; export * from './manager'; export * from './redis'; export * from './types'; -export * from './upstash'; export * from './utils'; diff --git a/src/libs/redis/manager.test.ts b/src/libs/redis/manager.test.ts index 7cad711c1d..3a2cb94669 100644 --- a/src/libs/redis/manager.test.ts +++ b/src/libs/redis/manager.test.ts @@ -1,23 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { RedisManager, initializeRedis, resetRedisClient } from './manager'; -import { DisabledRedisConfig } from './types'; +import { RedisConfig } from './types'; -const { - mockIoRedisInitialize, - mockIoRedisDisconnect, - mockUpstashInitialize, - mockUpstashDisconnect, -} = vi.hoisted(() => ({ +const { mockIoRedisInitialize, mockIoRedisDisconnect } = vi.hoisted(() => ({ mockIoRedisInitialize: vi.fn().mockResolvedValue(undefined), mockIoRedisDisconnect: vi.fn().mockResolvedValue(undefined), - mockUpstashInitialize: vi.fn().mockResolvedValue(undefined), - mockUpstashDisconnect: vi.fn().mockResolvedValue(undefined), })); vi.mock('./redis', () => { const IoRedisRedisProvider = vi.fn().mockImplementation((config) => ({ - provider: 'redis' as const, config, initialize: mockIoRedisInitialize, disconnect: mockIoRedisDisconnect, @@ -26,20 +18,9 @@ vi.mock('./redis', () => { return { IoRedisRedisProvider }; }); -vi.mock('./upstash', () => { - const UpstashRedisProvider = vi.fn().mockImplementation((config) => ({ - provider: 'upstash' as const, - config, - initialize: mockUpstashInitialize, - disconnect: mockUpstashDisconnect, - })); - - return { UpstashRedisProvider }; -}); - afterEach(async () => { - vi.clearAllMocks(); await RedisManager.reset(); + vi.clearAllMocks(); }); describe('RedisManager', () => { @@ -47,14 +28,14 @@ describe('RedisManager', () => { const config = { enabled: false, prefix: 'test', - provider: false, - } satisfies DisabledRedisConfig; + tls: false, + url: '', + } satisfies RedisConfig; const instance = await initializeRedis(config); expect(instance).toBeNull(); expect(mockIoRedisInitialize).not.toHaveBeenCalled(); - expect(mockUpstashInitialize).not.toHaveBeenCalled(); }); it('initializes ioredis provider once and memoizes the instance', async () => { @@ -63,41 +44,24 @@ describe('RedisManager', () => { enabled: true, password: 'pwd', prefix: 'test', - provider: 'redis' as const, tls: false, url: 'redis://localhost:6379', username: 'user', - }; + } satisfies RedisConfig; + const [first, second] = await Promise.all([initializeRedis(config), initializeRedis(config)]); expect(first).toBe(second); expect(mockIoRedisInitialize).toHaveBeenCalledTimes(1); - expect(mockUpstashInitialize).not.toHaveBeenCalled(); - }); - - it('initializes upstash provider when configured', async () => { - const config = { - enabled: true, - prefix: 'test', - provider: 'upstash' as const, - token: 'token', - url: 'https://example.upstash.io', - }; - const instance = await initializeRedis(config); - - expect(instance?.provider).toBe('upstash'); - expect(mockUpstashInitialize).toHaveBeenCalledTimes(1); - expect(mockIoRedisInitialize).not.toHaveBeenCalled(); }); it('disconnects existing provider on reset', async () => { const config = { enabled: true, prefix: 'test', - provider: 'redis' as const, tls: false, url: 'redis://localhost:6379', - }; + } satisfies RedisConfig; await initializeRedis(config); await resetRedisClient(); diff --git a/src/libs/redis/manager.ts b/src/libs/redis/manager.ts index 3c6c0d66a1..b6a366fa11 100644 --- a/src/libs/redis/manager.ts +++ b/src/libs/redis/manager.ts @@ -1,32 +1,18 @@ import { IoRedisRedisProvider } from './redis'; import { type BaseRedisProvider, type RedisConfig } from './types'; -import { UpstashRedisProvider } from './upstash'; /** * Create a Redis provider instance based on config * * @param config - Redis config * @param prefix - Optional custom prefix to override config.prefix - * @returns Provider instance or null if disabled/unsupported + * @returns Provider instance or null if disabled */ const createProvider = (config: RedisConfig, prefix?: string): BaseRedisProvider | null => { if (!config.enabled) return null; const actualPrefix = prefix ?? config.prefix; - - if (config.provider === 'redis') { - return new IoRedisRedisProvider({ ...config, prefix: actualPrefix }); - } - - if (config.provider === 'upstash') { - return new UpstashRedisProvider({ - prefix: actualPrefix, - token: config.token, - url: config.url, - }); - } - - return null; + return new IoRedisRedisProvider({ ...config, prefix: actualPrefix }); }; class RedisManager { diff --git a/src/libs/redis/redis.test.ts b/src/libs/redis/redis.test.ts index b5fc64e4b9..b2397ad4cc 100644 --- a/src/libs/redis/redis.test.ts +++ b/src/libs/redis/redis.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { IoRedisConfig } from './types'; +import { RedisConfig } from './types'; -const buildRedisConfig = (): IoRedisConfig | null => { +const buildRedisConfig = (): RedisConfig | null => { const url = process.env.REDIS_URL; if (!url) return null; @@ -14,7 +14,6 @@ const buildRedisConfig = (): IoRedisConfig | null => { enabled: true, password: process.env.REDIS_PASSWORD, prefix: process.env.REDIS_PREFIX ?? 'lobe-chat-test', - provider: 'redis', tls: process.env.REDIS_TLS === 'true', url, username: process.env.REDIS_USERNAME, @@ -79,7 +78,6 @@ const createMockedProvider = async () => { const provider = new IoRedisRedisProvider({ enabled: true, prefix: 'mock', - provider: 'redis', tls: false, url: 'redis://localhost:6379', }); diff --git a/src/libs/redis/redis.ts b/src/libs/redis/redis.ts index 4715a4bb45..27b6f3f794 100644 --- a/src/libs/redis/redis.ts +++ b/src/libs/redis/redis.ts @@ -3,10 +3,9 @@ import type { Redis } from 'ioredis'; import { type BaseRedisProvider, - type IoRedisConfig, + type RedisConfig, type RedisKey, type RedisMSetArgument, - type RedisProviderName, type RedisSetResult, type RedisValue, type SetOptions, @@ -16,10 +15,9 @@ import { buildIORedisSetArgs, normalizeMsetValues } from './utils'; const log = debug('lobe:redis'); export class IoRedisRedisProvider implements BaseRedisProvider { - provider: RedisProviderName = 'redis'; private client: Redis | null = null; - constructor(private config: IoRedisConfig) {} + constructor(private config: RedisConfig) {} async initialize() { const IORedis = await import('ioredis'); diff --git a/src/libs/redis/types.ts b/src/libs/redis/types.ts index 1e94a89fc9..f60acf8f51 100644 --- a/src/libs/redis/types.ts +++ b/src/libs/redis/types.ts @@ -1,35 +1,16 @@ export type RedisKey = string | Buffer; export type RedisValue = string | Buffer | number; -export type RedisProvider = false | 'redis' | 'upstash'; -export type RedisProviderName = Exclude; -export type IoRedisConfig = { +export type RedisConfig = { database?: number; enabled: boolean; password?: string; prefix: string; - provider: 'redis'; tls: boolean; url: string; username?: string; }; -export type UpstashConfig = { - enabled: boolean; - prefix: string; - provider: 'upstash'; - token: string; - url: string; -}; - -export type DisabledRedisConfig = { - enabled: false; - prefix: string; - provider: false; -}; - -export type RedisConfig = IoRedisConfig | UpstashConfig | DisabledRedisConfig; - export interface SetOptions { ex?: number; exat?: number; @@ -41,8 +22,7 @@ export interface SetOptions { xx?: boolean; } -// NOTICE: number comes from upstash -export type RedisSetResult = 'OK' | null | string | number; +export type RedisSetResult = 'OK' | null | string; export type RedisMSetArgument = Record | Map; export interface RedisClient { @@ -65,7 +45,5 @@ export interface RedisClient { export interface BaseRedisProvider extends RedisClient { disconnect(): Promise; - initialize(): Promise; - provider: RedisProviderName; } diff --git a/src/libs/redis/upstash.test.ts b/src/libs/redis/upstash.test.ts deleted file mode 100644 index c94d296e33..0000000000 --- a/src/libs/redis/upstash.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// @vitest-environment node -// NOTICE: here due to the reason we are using [`happy-dom`](https://github.com/lobehub/lobe-chat/blob/13753145557a9dede98b1f5489f93ac570ef2956/vitest.config.mts#L45) -// for Vitest environment, and in fact that this is a known bug for happy-dom not including -// Authorization header in fetch requests. -// -// Read more here: https://github.com/capricorn86/happy-dom/issues/1042#issuecomment-3585851354 -import { Buffer } from 'node:buffer'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { UpstashConfig } from './types'; - -const buildUpstashConfig = (): UpstashConfig | null => { - const url = process.env.UPSTASH_REDIS_REST_URL; - const token = process.env.UPSTASH_REDIS_REST_TOKEN; - - if (!url || !token) return null; - - return { - enabled: true, - prefix: process.env.REDIS_PREFIX ?? 'lobe-chat-test', - provider: 'upstash', - token, - url, - }; -}; - -const loadUpstashProvider = async () => (await import('./upstash')).UpstashRedisProvider; - -const createMockedProvider = async () => { - const mocks = { - mockSet: vi.fn().mockResolvedValue('OK'), - mockGet: vi.fn().mockResolvedValue('mock-value'), - mockDel: vi.fn().mockResolvedValue(1), - mockSetex: vi.fn().mockResolvedValue('OK'), - mockMset: vi.fn().mockResolvedValue('OK'), - mockHset: vi.fn().mockResolvedValue(1), - mockHdel: vi.fn().mockResolvedValue(1), - mockHgetall: vi.fn().mockResolvedValue({ a: '1' }), - mockPing: vi.fn().mockResolvedValue('PONG'), - mockExists: vi.fn().mockResolvedValue(1), - mockExpire: vi.fn().mockResolvedValue(1), - mockTtl: vi.fn().mockResolvedValue(50), - mockIncr: vi.fn().mockResolvedValue(2), - mockDecr: vi.fn().mockResolvedValue(0), - mockMget: vi.fn().mockResolvedValue(['a', 'b']), - mockHget: vi.fn().mockResolvedValue('field'), - }; - - vi.resetModules(); - vi.doMock('@upstash/redis', () => { - class FakeRedis { - constructor(public config: any) {} - ping = mocks.mockPing; - set = mocks.mockSet; - get = mocks.mockGet; - del = mocks.mockDel; - setex = mocks.mockSetex; - exists = mocks.mockExists; - expire = mocks.mockExpire; - ttl = mocks.mockTtl; - incr = mocks.mockIncr; - decr = mocks.mockDecr; - mget = mocks.mockMget; - mset = mocks.mockMset; - hget = mocks.mockHget; - hset = mocks.mockHset; - hdel = mocks.mockHdel; - hgetall = mocks.mockHgetall; - } - - return { Redis: FakeRedis }; - }); - - const UpstashRedisProvider = await loadUpstashProvider(); - const provider = new UpstashRedisProvider({ - enabled: true, - prefix: 'mock', - provider: 'upstash', - token: 'token', - url: 'https://example.upstash.io', - }); - - await provider.initialize(); - - return { mocks, provider }; -}; - -const shouldSkipIntegration = (error: unknown) => - error instanceof Error && - ['ENOTFOUND', 'ECONNREFUSED', 'EAI_AGAIN', 'Connection is closed'].some((msg) => - error.message.includes(msg), - ); - -afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - vi.unmock('@upstash/redis'); -}); - -describe('integrated', (test) => { - const config = buildUpstashConfig(); - if (!config) { - test.skip('UPSTASH_REDIS_REST_URL/TOKEN not provided; skip integrated upstash tests'); - return; - } - - it('set -> get -> del roundtrip', async () => { - vi.unmock('@upstash/redis'); - vi.resetModules(); - - const UpstashRedisProvider = await loadUpstashProvider(); - const provider = new UpstashRedisProvider(config); - try { - await provider.initialize(); - - const key = `upstash:test:${Date.now()}`; - await provider.set(key, 'value', { ex: 60 }); - expect(await provider.get(key)).toBe('value'); - expect(await provider.del(key)).toBe(1); - } catch (error) { - if (shouldSkipIntegration(error)) { - // Remote Upstash Redis unavailable in current environment; treat as skipped. - return; - } - - throw error; - } finally { - await provider.disconnect(); - } - }); -}); - -describe('mocked', () => { - it('normalizes buffer keys to strings', async () => { - const { mocks, provider } = await createMockedProvider(); - - const bufKey = Buffer.from('buffer-key'); - await provider.set(bufKey, 'value'); - await provider.hset(bufKey, 'field', 'value'); - await provider.del(bufKey); - - expect(mocks.mockSet).toHaveBeenCalledWith('mock:buffer-key', 'value', undefined); - expect(mocks.mockHset).toHaveBeenCalledWith('mock:buffer-key', { field: 'value' }); - expect(mocks.mockDel).toHaveBeenCalledWith('mock:buffer-key'); - }); - - it('passes set options through to upstash client', async () => { - const { mocks, provider } = await createMockedProvider(); - - await provider.set('key', 'value', { ex: 10, nx: true, get: true }); - - expect(mocks.mockSet).toHaveBeenCalledWith('mock:key', 'value', { - ex: 10, - nx: true, - get: true, - }); - }); -}); diff --git a/src/libs/redis/upstash.ts b/src/libs/redis/upstash.ts deleted file mode 100644 index b2ccd7c910..0000000000 --- a/src/libs/redis/upstash.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Redis, type RedisConfigNodejs } from '@upstash/redis'; -import { Buffer } from 'node:buffer'; - -import { - type BaseRedisProvider, - type RedisKey, - type RedisMSetArgument, - type RedisSetResult, - type RedisValue, - type SetOptions, - type UpstashConfig, -} from './types'; -import { - buildUpstashSetOptions, - normalizeMsetValues, - normalizeRedisKey, - normalizeRedisKeys, -} from './utils'; - -export class UpstashRedisProvider implements BaseRedisProvider { - provider: 'upstash' = 'upstash'; - private client: Redis; - private readonly prefix: string; - - constructor(options: UpstashConfig | RedisConfigNodejs) { - const { prefix, ...clientOptions } = options as UpstashConfig & RedisConfigNodejs; - this.prefix = prefix ? `${prefix}:` : ''; - this.client = new Redis({ - ...clientOptions, - automaticDeserialization: false, - } as RedisConfigNodejs); - } - - /** - * Build a fully qualified key assuming the input was already normalized. - * Avoids re-running normalization when callers have normalized keys (e.g. mset). - */ - private addPrefixToKey(normalizedKey: string) { - return `${this.prefix}${normalizedKey}`; - } - - private buildKey(key: RedisKey) { - return this.addPrefixToKey(normalizeRedisKey(key)); - } - - private buildKeys(keys: RedisKey[]) { - return normalizeRedisKeys(keys).map((key) => `${this.prefix}${key}`); - } - - async initialize(): Promise { - await this.client.ping(); - } - - async disconnect() { - // upstash client is stateless http, nothing to disconnect - } - - async get(key: RedisKey): Promise { - return this.client.get(this.buildKey(key)); - } - - async set(key: RedisKey, value: RedisValue, options?: SetOptions): Promise { - const res = await this.client.set(this.buildKey(key), value, buildUpstashSetOptions(options)); - if (Buffer.isBuffer(res)) { - return res.toString(); - } - - return res; - } - - async setex(key: RedisKey, seconds: number, value: RedisValue): Promise<'OK'> { - return this.client.setex(this.buildKey(key), seconds, value); - } - - async del(...keys: RedisKey[]): Promise { - return this.client.del(...this.buildKeys(keys)); - } - - async exists(...keys: RedisKey[]): Promise { - return this.client.exists(...this.buildKeys(keys)); - } - - async expire(key: RedisKey, seconds: number): Promise { - return this.client.expire(this.buildKey(key), seconds); - } - - async ttl(key: RedisKey): Promise { - return this.client.ttl(this.buildKey(key)); - } - - async incr(key: RedisKey): Promise { - return this.client.incr(this.buildKey(key)); - } - - async decr(key: RedisKey): Promise { - return this.client.decr(this.buildKey(key)); - } - - async mget(...keys: RedisKey[]): Promise<(string | null)[]> { - return this.client.mget(...this.buildKeys(keys)); - } - - async mset(values: RedisMSetArgument): Promise<'OK'> { - const normalized = normalizeMsetValues(values); - const prefixed = Object.entries(normalized).reduce>( - (acc, [key, value]) => { - acc[this.addPrefixToKey(key)] = value; - return acc; - }, - {}, - ); - - return this.client.mset(prefixed); - } - - async hget(key: RedisKey, field: RedisKey): Promise { - return this.client.hget(this.buildKey(key), normalizeRedisKey(field)); - } - - async hset(key: RedisKey, field: RedisKey, value: RedisValue): Promise { - return this.client.hset(this.buildKey(key), { [normalizeRedisKey(field)]: value }); - } - - async hdel(key: RedisKey, ...fields: RedisKey[]): Promise { - return this.client.hdel(this.buildKey(key), ...normalizeRedisKeys(fields)); - } - - async hgetall(key: RedisKey): Promise> { - const res = await this.client.hgetall(this.buildKey(key)); - if (!res) { - return {}; - } - - return res as Record; - } -} diff --git a/src/libs/redis/utils.test.ts b/src/libs/redis/utils.test.ts index 23b9c75792..7878a04e15 100644 --- a/src/libs/redis/utils.test.ts +++ b/src/libs/redis/utils.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { buildIORedisSetArgs, - buildUpstashSetOptions, normalizeMsetValues, normalizeRedisKey, normalizeRedisKeys, @@ -34,13 +33,4 @@ describe('redis utils', () => { expect(args).toEqual(['EX', 1, 'NX', 'GET']); }); - - it('builds upstash set options', () => { - expect(buildUpstashSetOptions()).toBeUndefined(); - expect(buildUpstashSetOptions({ ex: 10, nx: true, get: true })).toEqual({ - ex: 10, - nx: true, - get: true, - }); - }); }); diff --git a/src/libs/redis/utils.ts b/src/libs/redis/utils.ts index 6bb72cbbef..65a7bb0c58 100644 --- a/src/libs/redis/utils.ts +++ b/src/libs/redis/utils.ts @@ -1,5 +1,3 @@ -import type { SetCommandOptions } from '@upstash/redis'; - import { type RedisKey, type RedisMSetArgument, type RedisValue, type SetOptions } from './types'; export const normalizeRedisKey = (key: RedisKey) => @@ -34,20 +32,3 @@ export const buildIORedisSetArgs = (options?: SetOptions): Array { - if (!options) return undefined; - - const mapped: Partial = {}; - - if (options.ex !== undefined) mapped.ex = options.ex; - if (options.px !== undefined) mapped.px = options.px; - if (options.exat !== undefined) mapped.exat = options.exat; - if (options.pxat !== undefined) mapped.pxat = options.pxat; - if (options.keepTtl) mapped.keepTtl = true; - if (options.nx) mapped.nx = true; - if (options.xx) mapped.xx = true; - if (options.get) mapped.get = true; - - return Object.keys(mapped).length ? (mapped as SetCommandOptions) : undefined; -}; diff --git a/src/libs/trpc/lambda/context.test.ts b/src/libs/trpc/lambda/context.test.ts index 53d0c12b12..bed6fb58fd 100644 --- a/src/libs/trpc/lambda/context.test.ts +++ b/src/libs/trpc/lambda/context.test.ts @@ -9,7 +9,6 @@ describe('createContextInner', () => { expect(context).toMatchObject({ authorizationHeader: undefined, marketAccessToken: undefined, - nextAuth: undefined, oidcAuth: undefined, userAgent: undefined, userId: undefined, @@ -58,18 +57,6 @@ describe('createContextInner', () => { expect(context.oidcAuth).toEqual(oidcAuth); }); - it('should create context with NextAuth user data', async () => { - const nextAuth = { - id: 'next-auth-user-id', - name: 'Test User', - email: 'test@example.com', - }; - - const context = await createContextInner({ nextAuth }); - - expect(context.nextAuth).toEqual(nextAuth); - }); - it('should create context with all parameters combined', async () => { const params = { authorizationHeader: 'Bearer token', diff --git a/src/libs/trpc/lambda/context.ts b/src/libs/trpc/lambda/context.ts index 8b37532171..acb172ff5f 100644 --- a/src/libs/trpc/lambda/context.ts +++ b/src/libs/trpc/lambda/context.ts @@ -1,16 +1,10 @@ import { type ClientSecretPayload } from '@lobechat/types'; import { parse } from 'cookie'; import debug from 'debug'; -import { type User } from 'next-auth'; import { type NextRequest } from 'next/server'; -import { - LOBE_CHAT_AUTH_HEADER, - LOBE_CHAT_OIDC_AUTH_HEADER, - authEnv, - enableBetterAuth, - enableNextAuth, -} from '@/envs/auth'; +import { auth } from '@/auth'; +import { LOBE_CHAT_AUTH_HEADER, LOBE_CHAT_OIDC_AUTH_HEADER, authEnv } from '@/envs/auth'; import { validateOIDCJWT } from '@/libs/oidc-provider/jwt'; // Create context logger namespace @@ -43,7 +37,6 @@ export interface AuthContext { clientIp?: string | null; jwtPayload?: ClientSecretPayload | null; marketAccessToken?: string; - nextAuth?: User; // Add OIDC authentication information oidcAuth?: OIDCAuth | null; resHeaders?: Headers; @@ -59,7 +52,6 @@ export const createContextInner = async (params?: { authorizationHeader?: string | null; clientIp?: string | null; marketAccessToken?: string; - nextAuth?: User; oidcAuth?: OIDCAuth | null; userAgent?: string; userId?: string | null; @@ -71,7 +63,6 @@ export const createContextInner = async (params?: { authorizationHeader: params?.authorizationHeader, clientIp: params?.clientIp, marketAccessToken: params?.marketAccessToken, - nextAuth: params?.nextAuth, oidcAuth: params?.oidcAuth, resHeaders: responseHeaders, userAgent: params?.userAgent, @@ -120,7 +111,6 @@ export const createLambdaContext = async (request: NextRequest): Promise { @@ -9,11 +7,7 @@ export const userAuth = trpc.middleware(async (opts) => { // `ctx.user` is nullable if (!ctx.userId) { - if (enableBetterAuth) { - console.log('better auth: no session found in context'); - } else if (enableNextAuth) { - console.log('next auth:', ctx.nextAuth); - } + console.log('better auth: no session found in context'); throw new TRPCError({ code: 'UNAUTHORIZED' }); } diff --git a/src/libs/trusted-client/getSessionUser.ts b/src/libs/trusted-client/getSessionUser.ts index 92ce78c752..c80a26cd45 100644 --- a/src/libs/trusted-client/getSessionUser.ts +++ b/src/libs/trusted-client/getSessionUser.ts @@ -1,50 +1,30 @@ -import { enableBetterAuth, enableNextAuth } from '@/envs/auth'; +import { headers } from 'next/headers'; import type { TrustedClientUserInfo } from './index'; /** * Get user info from the current session for trusted client authentication - * This works with different authentication providers (BetterAuth, NextAuth) * * @returns User info or undefined if not authenticated */ export const getSessionUser = async (): Promise => { try { - if (enableBetterAuth) { - const { headers } = await import('next/headers'); - const { auth } = await import('@/auth'); - const headersList = await headers(); - const session = await auth.api.getSession({ - headers: headersList, - }); + // Dynamic import to avoid validator ESM/CJS issue during sitemap generation + const { auth } = await import('@/auth'); + const headersList = await headers(); + const session = await auth.api.getSession({ + headers: headersList, + }); - if (!session?.user?.id || !session?.user?.email) { - return undefined; - } - - return { - email: session.user.email, - name: session.user.name || undefined, - userId: session.user.id, - }; + if (!session?.user?.id || !session?.user?.email) { + return undefined; } - if (enableNextAuth) { - const { default: NextAuth } = await import('@/libs/next-auth'); - const session = await NextAuth.auth(); - - if (!session?.user?.id || !session?.user?.email) { - return undefined; - } - - return { - email: session.user.email, - name: session.user.name || undefined, - userId: session.user.id, - }; - } - - return undefined; + return { + email: session.user.email, + name: session.user.name || undefined, + userId: session.user.id, + }; } catch { return undefined; } diff --git a/src/server/globalConfig/index.ts b/src/server/globalConfig/index.ts index 6286d36650..8c46676666 100644 --- a/src/server/globalConfig/index.ts +++ b/src/server/globalConfig/index.ts @@ -90,9 +90,7 @@ export const getServerGlobalConfig = async () => { memory: { userMemory: cleanObject(getPublicMemoryExtractionConfig()), }, - oAuthSSOProviders: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH - ? getBetterAuthSSOProviders() - : authEnv.NEXT_AUTH_SSO_PROVIDERS.trim().split(/[,,]/), + oAuthSSOProviders: getBetterAuthSSOProviders(), systemAgent: parseSystemAgent(appEnv.SYSTEM_AGENT), telemetry: { langfuse: langfuseEnv.ENABLE_LANGFUSE, diff --git a/src/server/routers/lambda/__tests__/user.test.ts b/src/server/routers/lambda/__tests__/user.test.ts index 2d52b57a06..7db3b7853b 100644 --- a/src/server/routers/lambda/__tests__/user.test.ts +++ b/src/server/routers/lambda/__tests__/user.test.ts @@ -6,7 +6,6 @@ import { SessionModel } from '@/database/models/session'; import { UserModel, UserNotFoundError } from '@/database/models/user'; import { serverDB } from '@/database/server'; import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; -import { NextAuthUserService } from '@/server/services/nextAuthUser'; import { UserService } from '@/server/services/user'; import { userRouter } from '../user'; @@ -22,11 +21,6 @@ vi.mock('@/database/models/user'); vi.mock('@/server/modules/KeyVaultsEncrypt'); vi.mock('@/server/modules/S3'); vi.mock('@/server/services/user'); -vi.mock('@/server/services/nextAuthUser'); -vi.mock('@/envs/auth', () => ({ - enableBetterAuth: false, - enableNextAuth: false, -})); describe('userRouter', () => { const mockUserId = 'test-user-id'; @@ -122,7 +116,6 @@ describe('userRouter', () => { userId: mockUserId, }); }); - }); describe('makeUserOnboarded', () => { @@ -140,47 +133,6 @@ describe('userRouter', () => { }); }); - describe('unlinkSSOProvider', () => { - it('should unlink SSO provider successfully', async () => { - const mockInput = { - provider: 'google', - providerAccountId: '123', - }; - - const mockAccount = { - userId: mockUserId, - provider: 'google', - providerAccountId: '123', - type: 'oauth', - }; - - vi.mocked(NextAuthUserService).mockReturnValue({ - getAccount: vi.fn().mockResolvedValue(mockAccount), - unlinkAccount: vi.fn().mockResolvedValue(undefined), - } as any); - - await expect( - userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput), - ).resolves.not.toThrow(); - }); - - it('should throw error if account does not exist', async () => { - const mockInput = { - provider: 'google', - providerAccountId: '123', - }; - - vi.mocked(NextAuthUserService).mockReturnValue({ - getAccount: vi.fn().mockResolvedValue(null), - unlinkAccount: vi.fn(), - } as any); - - await expect( - userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput), - ).rejects.toThrow('The account does not exist'); - }); - }); - describe('updateSettings', () => { it('should update settings with encrypted key vaults', async () => { const mockSettings = { diff --git a/src/server/routers/lambda/user.ts b/src/server/routers/lambda/user.ts index 8ae58ed866..d5b011ce15 100644 --- a/src/server/routers/lambda/user.ts +++ b/src/server/routers/lambda/user.ts @@ -1,6 +1,5 @@ import { isDesktop } from '@lobechat/const'; import { - NextAuthAccountSchame, Plans, UserGuideSchema, type UserInitializationState, @@ -16,8 +15,8 @@ import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import { - getIsInviteCodeRequired, getIsInWaitList, + getIsInviteCodeRequired, getReferralStatus, getSubscriptionPlan, } from '@/business/server/user'; @@ -29,7 +28,6 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware'; import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; import { FileS3 } from '@/server/modules/S3'; import { FileService } from '@/server/services/file'; -import { NextAuthUserService } from '@/server/services/nextAuthUser'; const usernameSchema = z .string() @@ -42,7 +40,6 @@ const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next ctx: { fileService: new FileService(ctx.serverDB, ctx.userId), messageModel: new MessageModel(ctx.serverDB, ctx.userId), - nextAuthUserService: new NextAuthUserService(ctx.serverDB), sessionModel: new SessionModel(ctx.serverDB, ctx.userId), userModel: new UserModel(ctx.serverDB, ctx.userId), }, @@ -138,14 +135,6 @@ export const userRouter = router({ return ctx.userModel.deleteSetting(); }), - unlinkSSOProvider: userProcedure.input(NextAuthAccountSchame).mutation(async ({ ctx, input }) => { - const { provider, providerAccountId } = input; - const account = await ctx.nextAuthUserService.getAccount(providerAccountId, provider); - // The userId can either get from ctx.nextAuth?.id or ctx.userId - if (!account || account.userId !== ctx.userId) throw new Error('The account does not exist'); - await ctx.nextAuthUserService.unlinkAccount({ provider, providerAccountId }); - }), - updateAvatar: userProcedure.input(z.string()).mutation(async ({ ctx, input }) => { // If it's Base64 data, need to upload to S3 if (input.startsWith('data:image')) { diff --git a/src/server/services/email/impls/nodemailer/index.ts b/src/server/services/email/impls/nodemailer/index.ts index 1718ee12a7..f7426d91b2 100644 --- a/src/server/services/email/impls/nodemailer/index.ts +++ b/src/server/services/email/impls/nodemailer/index.ts @@ -49,8 +49,8 @@ export class NodemailerImpl implements EmailServiceImpl { } async sendMail(payload: EmailPayload): Promise { - // Use SMTP_USER as default sender if not provided - const from = payload.from ?? emailEnv.SMTP_USER!; + // Use SMTP_FROM as default sender, fallback to SMTP_USER for backward compatibility + const from = payload.from ?? emailEnv.SMTP_FROM ?? emailEnv.SMTP_USER!; log('Sending email with payload: %o', { from, diff --git a/src/server/services/nextAuthUser/index.ts b/src/server/services/nextAuthUser/index.ts deleted file mode 100644 index aa8651a0d1..0000000000 --- a/src/server/services/nextAuthUser/index.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { type LobeChatDatabase } from '@lobechat/database'; -import { and, eq } from 'drizzle-orm'; -import { type Adapter, type AdapterAccount } from 'next-auth/adapters'; -import { NextResponse } from 'next/server'; - -import { UserModel } from '@/database/models/user'; -import { - type UserItem, - nextauthAccounts, - nextauthAuthenticators, - nextauthSessions, - nextauthVerificationTokens, - users, -} from '@/database/schemas'; -import { pino } from '@/libs/logger'; -import { merge } from '@/utils/merge'; - -import { - mapAdapterUserToLobeUser, - mapAuthenticatorQueryResutlToAdapterAuthenticator, - mapLobeUserToAdapterUser, - partialMapAdapterUserToLobeUser, -} from './utils'; - -export class NextAuthUserService { - private db: LobeChatDatabase; - - constructor(db: LobeChatDatabase) { - this.db = db; - } - - safeUpdateUser = async ( - { providerAccountId, provider }: { provider: string; providerAccountId: string }, - data: Partial, - ) => { - pino.info(`updating user "${JSON.stringify({ provider, providerAccountId })}" due to webhook`); - // 1. Find User by account - const user = await this.getUserByAccount({ - provider, - providerAccountId, - }); - - // 2. If found, Update user data from provider - if (user?.id) { - const userModel = new UserModel(this.db, user.id); - - // Perform update - await userModel.updateUser({ - avatar: data?.avatar, - email: data?.email, - fullName: data?.fullName, - }); - } else { - pino.warn( - `[${provider}]: Webhooks handler user "${JSON.stringify({ provider, providerAccountId })}" update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`, - ); - } - return NextResponse.json({ message: 'user updated', success: true }, { status: 200 }); - }; - - safeSignOutUser = async ({ - providerAccountId, - provider, - }: { - provider: string; - providerAccountId: string; - }) => { - pino.info(`Signing out user "${JSON.stringify({ provider, providerAccountId })}"`); - const user = await this.getUserByAccount({ - provider, - providerAccountId, - }); - - // 2. If found, Update user data from provider - if (user?.id) { - // Perform update - await this.db.delete(nextauthSessions).where(eq(nextauthSessions.userId, user.id)); - } else { - pino.warn( - `[${provider}]: Webhooks handler user "${JSON.stringify({ provider, providerAccountId })}" to signout", but no user was found by the providerAccountId.`, - ); - } - return NextResponse.json({ message: 'user signed out', success: true }, { status: 200 }); - }; - - createAuthenticator: NonNullable = async (authenticator) => { - return await this.db - .insert(nextauthAuthenticators) - .values(authenticator) - .returning() - .then((res: any) => res[0] ?? undefined); - }; - - createSession: NonNullable = async (data) => { - return await this.db - .insert(nextauthSessions) - .values(data) - .returning() - .then((res: any) => res[0]); - }; - - createUser: NonNullable = async (user) => { - const { id, name, email, emailVerified, image, providerAccountId } = user; - // return the user if it already exists - let existingUser = - email && typeof email === 'string' && email.trim() - ? await UserModel.findByEmail(this.db, email) - : undefined; - // If the user is not found by email, try to find by providerAccountId - if (!existingUser && providerAccountId) { - existingUser = await UserModel.findById(this.db, providerAccountId); - } - if (existingUser) { - const adapterUser = mapLobeUserToAdapterUser(existingUser); - return adapterUser; - } - - // create a new user if it does not exist - // Use id from provider if it exists, otherwise use id assigned by next-auth - // ref: https://github.com/lobehub/lobe-chat/pull/2935 - const uid = providerAccountId ?? id; - await UserModel.createUser( - this.db, - mapAdapterUserToLobeUser({ - email, - emailVerified, - // Use providerAccountId as userid to identify if the user exists in a SSO provider - id: uid, - image, - name, - }), - ); - - return { ...user, id: uid }; - }; - - createVerificationToken: NonNullable = async (data) => { - return await this.db - .insert(nextauthVerificationTokens) - .values(data) - .returning() - .then((res: any) => res[0]); - }; - - deleteSession: NonNullable = async (sessionToken) => { - await this.db.delete(nextauthSessions).where(eq(nextauthSessions.sessionToken, sessionToken)); - }; - - deleteUser: NonNullable = async (id) => { - const user = await UserModel.findById(this.db, id); - if (!user) throw new Error('NextAuth: Delete User not found'); - await UserModel.deleteUser(this.db, id); - }; - - getAccount: NonNullable = async (providerAccountId, provider) => { - return (await this.db - .select() - .from(nextauthAccounts) - .where( - and( - eq(nextauthAccounts.provider, provider), - eq(nextauthAccounts.providerAccountId, providerAccountId), - ), - ) - .then((res: any) => res[0] ?? null)) as Promise; - }; - - getAuthenticator: NonNullable = async (credentialID) => { - const result = await this.db - .select() - .from(nextauthAuthenticators) - .where(eq(nextauthAuthenticators.credentialID, credentialID)) - .then((res) => res[0] ?? null); - if (!result) throw new Error('NextAuthUserService: Failed to get authenticator'); - return mapAuthenticatorQueryResutlToAdapterAuthenticator(result); - }; - - getSessionAndUser: NonNullable = async (sessionToken) => { - const result = await this.db - .select({ - session: nextauthSessions, - user: users, - }) - .from(nextauthSessions) - .where(eq(nextauthSessions.sessionToken, sessionToken)) - .innerJoin(users, eq(users.id, nextauthSessions.userId)) - .then((res: any) => (res.length > 0 ? res[0] : null)); - - if (!result) return null; - const adapterUser = mapLobeUserToAdapterUser(result.user); - if (!adapterUser) return null; - return { - session: result.session, - user: adapterUser, - }; - }; - - getUser: NonNullable = async (id) => { - const lobeUser = await UserModel.findById(this.db, id); - if (!lobeUser) return null; - return mapLobeUserToAdapterUser(lobeUser); - }; - - getUserByAccount: NonNullable = async (account) => { - const result = await this.db - .select({ - account: nextauthAccounts, - users, - }) - .from(nextauthAccounts) - .innerJoin(users, eq(nextauthAccounts.userId, users.id)) - .where( - and( - eq(nextauthAccounts.provider, account.provider), - eq(nextauthAccounts.providerAccountId, account.providerAccountId), - ), - ) - .then((res: any) => res[0]); - - return result?.users ? mapLobeUserToAdapterUser(result.users) : null; - }; - - getUserByEmail: NonNullable = async (email) => { - const lobeUser = - email && typeof email === 'string' && email.trim() - ? await UserModel.findByEmail(this.db, email) - : undefined; - return lobeUser ? mapLobeUserToAdapterUser(lobeUser) : null; - }; - - linkAccount: NonNullable = async (data) => { - const [account] = await this.db - .insert(nextauthAccounts) - .values(data as any) - .returning(); - if (!account) throw new Error('NextAuthAccountModel: Failed to create account'); - // TODO Update type annotation - return account as any; - }; - - listAuthenticatorsByUserId: NonNullable = async ( - userId, - ) => { - const result = await this.db - .select() - .from(nextauthAuthenticators) - .where(eq(nextauthAuthenticators.userId, userId)) - .then((res: any) => res); - if (result.length === 0) - throw new Error('NextAuthUserService: Failed to get authenticator list'); - return result.map((r: any) => mapAuthenticatorQueryResutlToAdapterAuthenticator(r)); - }; - - unlinkAccount: NonNullable = async (account) => { - await this.db - .delete(nextauthAccounts) - .where( - and( - eq(nextauthAccounts.provider, account.provider), - eq(nextauthAccounts.providerAccountId, account.providerAccountId), - ), - ); - }; - - updateAuthenticatorCounter: NonNullable = async ( - credentialID, - counter, - ) => { - const result = await this.db - .update(nextauthAuthenticators) - .set({ counter }) - .where(eq(nextauthAuthenticators.credentialID, credentialID)) - .returning() - .then((res: any) => res[0]); - if (!result) throw new Error('NextAuthUserService: Failed to update authenticator counter'); - return mapAuthenticatorQueryResutlToAdapterAuthenticator(result); - }; - - updateSession: NonNullable = async (data) => { - const res = await this.db - .update(nextauthSessions) - .set(data) - .where(eq(nextauthSessions.sessionToken, data.sessionToken)) - .returning(); - return res[0]; - }; - - updateUser: NonNullable = async (user) => { - const lobeUser = await UserModel.findById(this.db, user?.id); - if (!lobeUser) throw new Error('NextAuth: User not found'); - const userModel = new UserModel(this.db, user.id); - - const updatedUser = await userModel.updateUser({ - ...partialMapAdapterUserToLobeUser(user), - }); - if (!updatedUser) throw new Error('NextAuth: Failed to update user'); - - // merge new user data with old user data - const newAdapterUser = mapLobeUserToAdapterUser(lobeUser); - if (!newAdapterUser) { - throw new Error('NextAuth: Failed to map user data to adapter user'); - } - return merge(newAdapterUser, user); - }; - - useVerificationToken: NonNullable = async (identifier_token) => { - return await this.db - .delete(nextauthVerificationTokens) - .where( - and( - eq(nextauthVerificationTokens.identifier, identifier_token.identifier), - eq(nextauthVerificationTokens.token, identifier_token.token), - ), - ) - .returning() - .then((res: any) => (res.length > 0 ? res[0] : null)); - }; -} diff --git a/src/server/services/nextAuthUser/utils.ts b/src/server/services/nextAuthUser/utils.ts deleted file mode 100644 index ff4a83f6e7..0000000000 --- a/src/server/services/nextAuthUser/utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type AdapterAuthenticator, type AdapterUser } from 'next-auth/adapters'; - -import { type NewUser } from '@/database/schemas'; - -export const mapAdapterUserToLobeUser = (adapterUser: AdapterUser): NewUser => { - const { id, email, name, image, emailVerified } = adapterUser; - return { - avatar: image, - email, - emailVerifiedAt: emailVerified ? new Date(emailVerified) : undefined, - fullName: name, - id, - }; -}; - -export const partialMapAdapterUserToLobeUser = ({ - id, - name, - email, - image, - emailVerified, -}: Partial): Partial => { - return { - avatar: image, - email, - emailVerifiedAt: emailVerified ? new Date(emailVerified) : undefined, - fullName: name, - id, - }; -}; - -export const mapLobeUserToAdapterUser = (lobeUser: NewUser): AdapterUser => { - const { id, fullName, email, avatar, emailVerifiedAt } = lobeUser; - return { - // In LobeUser, email is nullable - email: email ?? '', - emailVerified: emailVerifiedAt ? new Date(emailVerifiedAt) : null, - id, - image: avatar, - name: fullName, - }; -}; - -type AuthenticatorQueryResult = { - counter: number; - credentialBackedUp: boolean; - credentialDeviceType: string; - credentialID: string; - credentialPublicKey: string; - providerAccountId: string; - transports: string | null; - userId: string; -}; - -export const mapAuthenticatorQueryResutlToAdapterAuthenticator = ( - authenticator: AuthenticatorQueryResult, -): AdapterAuthenticator => { - return { - ...authenticator, - transports: authenticator?.transports ?? undefined, - }; -}; diff --git a/src/server/services/webhookUser/index.ts b/src/server/services/webhookUser/index.ts new file mode 100644 index 0000000000..42202a1b22 --- /dev/null +++ b/src/server/services/webhookUser/index.ts @@ -0,0 +1,88 @@ +import { type LobeChatDatabase } from '@lobechat/database'; +import { and, eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; + +import { UserModel } from '@/database/models/user'; +import { type UserItem, account, session } from '@/database/schemas'; +import { pino } from '@/libs/logger'; + +export class WebhookUserService { + private db: LobeChatDatabase; + + constructor(db: LobeChatDatabase) { + this.db = db; + } + + /** + * Find user by provider account info + */ + private getUserByAccount = async ({ + providerId, + accountId, + }: { + accountId: string; + providerId: string; + }) => { + const result = await this.db.query.account.findFirst({ + where: and(eq(account.providerId, providerId), eq(account.accountId, accountId)), + }); + + if (!result) return null; + + return this.db.query.users.findFirst({ + where: eq(account.userId, result.userId), + }); + }; + + /** + * Safely update user data from webhook + */ + safeUpdateUser = async ( + { accountId, providerId }: { accountId: string; providerId: string }, + data: Partial, + ) => { + pino.info(`updating user "${JSON.stringify({ accountId, providerId })}" due to webhook`); + + const user = await this.getUserByAccount({ accountId, providerId }); + + if (user?.id) { + const userModel = new UserModel(this.db, user.id); + await userModel.updateUser({ + avatar: data?.avatar, + email: data?.email, + fullName: data?.fullName, + }); + } else { + pino.warn( + `[${providerId}]: Webhook user "${JSON.stringify({ accountId, providerId })}" update for "${JSON.stringify(data)}", but no user was found.`, + ); + } + + return NextResponse.json({ message: 'user updated', success: true }, { status: 200 }); + }; + + /** + * Safely sign out user (delete all sessions) + */ + safeSignOutUser = async ({ + accountId, + providerId, + }: { + accountId: string; + providerId: string; + }) => { + pino.info(`Signing out user "${JSON.stringify({ accountId, providerId })}"`); + + const user = await this.getUserByAccount({ accountId, providerId }); + + if (user?.id) { + await this.db.delete(session).where(eq(session.userId, user.id)); + } else { + pino.warn( + `[${providerId}]: Webhook user "${JSON.stringify({ accountId, providerId })}" signout, but no user was found.`, + ); + } + + return NextResponse.json({ message: 'user signed out', success: true }, { status: 200 }); + }; +} diff --git a/src/services/user/index.test.ts b/src/services/user/index.test.ts index 43dceafaaa..80ed109830 100644 --- a/src/services/user/index.test.ts +++ b/src/services/user/index.test.ts @@ -8,7 +8,6 @@ const mockLambdaClient = vi.hoisted(() => ({ getUserRegistrationDuration: { query: vi.fn() }, getUserState: { query: vi.fn() }, getUserSSOProviders: { query: vi.fn() }, - unlinkSSOProvider: { mutate: vi.fn() }, makeUserOnboarded: { mutate: vi.fn() }, updateAvatar: { mutate: vi.fn() }, updateFullName: { mutate: vi.fn() }, @@ -64,19 +63,6 @@ describe('UserService', () => { }); }); - describe('unlinkSSOProvider', () => { - it('should call lambdaClient.user.unlinkSSOProvider.mutate with correct params', async () => { - mockLambdaClient.user.unlinkSSOProvider.mutate.mockResolvedValueOnce({ success: true }); - - await userService.unlinkSSOProvider('github', 'account-123'); - - expect(mockLambdaClient.user.unlinkSSOProvider.mutate).toHaveBeenCalledWith({ - provider: 'github', - providerAccountId: 'account-123', - }); - }); - }); - describe('makeUserOnboarded', () => { it('should call lambdaClient.user.makeUserOnboarded.mutate', async () => { mockLambdaClient.user.makeUserOnboarded.mutate.mockResolvedValueOnce({ success: true }); diff --git a/src/services/user/index.ts b/src/services/user/index.ts index a8ca91cae2..b46d642b2b 100644 --- a/src/services/user/index.ts +++ b/src/services/user/index.ts @@ -27,10 +27,6 @@ export class UserService { return lambdaClient.user.getUserSSOProviders.query(); }; - unlinkSSOProvider = async (provider: string, providerAccountId: string) => { - return lambdaClient.user.unlinkSSOProvider.mutate({ provider, providerAccountId }); - }; - makeUserOnboarded = async () => { return lambdaClient.user.makeUserOnboarded.mutate(); }; diff --git a/src/store/user/slices/auth/action.test.ts b/src/store/user/slices/auth/action.test.ts index b873bfdb87..a061544c90 100644 --- a/src/store/user/slices/auth/action.test.ts +++ b/src/store/user/slices/auth/action.test.ts @@ -15,29 +15,6 @@ vi.mock('@/libs/swr', async () => { }; }); -// Use vi.hoisted to ensure variables exist before vi.mock factory executes -const { enableNextAuth, enableBetterAuth } = vi.hoisted(() => ({ - enableNextAuth: { value: false }, - enableBetterAuth: { value: false }, -})); - -vi.mock('@/envs/auth', () => ({ - get enableNextAuth() { - return enableNextAuth.value; - }, - get enableBetterAuth() { - return enableBetterAuth.value; - }, -})); - -const mockUserService = vi.hoisted(() => ({ - getUserSSOProviders: vi.fn().mockResolvedValue([]), -})); - -vi.mock('@/services/user', () => ({ - userService: mockUserService, -})); - const mockBetterAuthClient = vi.hoisted(() => ({ listAccounts: vi.fn().mockResolvedValue({ data: [] }), accountInfo: vi.fn().mockResolvedValue({ data: { user: {} } }), @@ -50,9 +27,6 @@ afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - enableNextAuth.value = false; - enableBetterAuth.value = false; - // Reset store state useUserStore.setState({ isLoadedAuthProviders: false, @@ -61,16 +35,6 @@ afterEach(() => { }); }); -/** - * Mock nextauth 库相关方法 - */ -vi.mock('next-auth/react', async () => { - return { - signIn: vi.fn(), - signOut: vi.fn(), - }; -}); - describe('createAuthSlice', () => { describe('refreshUserState', () => { it('should refresh user config', async () => { @@ -85,64 +49,19 @@ describe('createAuthSlice', () => { }); describe('logout', () => { - it('should call next-auth signOut when NextAuth is enabled', async () => { - enableNextAuth.value = true; - + it('should call better-auth signOut', async () => { const { result } = renderHook(() => useUserStore()); await act(async () => { await result.current.logout(); }); - const { signOut } = await import('next-auth/react'); - - expect(signOut).toHaveBeenCalled(); - enableNextAuth.value = false; - }); - - it('should not call next-auth signOut when NextAuth is disabled', async () => { - const { result } = renderHook(() => useUserStore()); - - await act(async () => { - await result.current.logout(); - }); - - const { signOut } = await import('next-auth/react'); - - expect(signOut).not.toHaveBeenCalled(); + expect(mockBetterAuthClient.signOut).toHaveBeenCalled(); }); }); describe('openLogin', () => { - it('should call next-auth signIn when NextAuth is enabled', async () => { - enableNextAuth.value = true; - - const { result } = renderHook(() => useUserStore()); - - await act(async () => { - await result.current.openLogin(); - }); - - const { signIn } = await import('next-auth/react'); - - expect(signIn).toHaveBeenCalled(); - enableNextAuth.value = false; - }); - it('should not call next-auth signIn when NextAuth is disabled', async () => { - const { result } = renderHook(() => useUserStore()); - - await act(async () => { - await result.current.openLogin(); - }); - - const { signIn } = await import('next-auth/react'); - - expect(signIn).not.toHaveBeenCalled(); - }); - - it('should redirect to signin page when BetterAuth is enabled', async () => { - enableBetterAuth.value = true; - + it('should redirect to signin page', async () => { const originalLocation = window.location; Object.defineProperty(window, 'location', { configurable: true, @@ -171,18 +90,15 @@ describe('createAuthSlice', () => { }); }); - it('should call signIn with single provider when only one OAuth provider available', async () => { - enableNextAuth.value = true; - useUserStore.setState({ oAuthSSOProviders: ['github'] }); - + it('should not redirect when already on signin page', async () => { const originalLocation = window.location; Object.defineProperty(window, 'location', { configurable: true, value: { ...originalLocation, href: '', - pathname: '/chat', - toString: () => 'http://localhost/chat', + pathname: '/signin', + toString: () => 'http://localhost/signin', }, writable: true, }); @@ -193,9 +109,7 @@ describe('createAuthSlice', () => { await result.current.openLogin(); }); - const { signIn } = await import('next-auth/react'); - - expect(signIn).toHaveBeenCalledWith('github'); + expect(window.location.href).toBe(''); Object.defineProperty(window, 'location', { configurable: true, @@ -215,29 +129,10 @@ describe('createAuthSlice', () => { await result.current.fetchAuthProviders(); }); - expect(mockUserService.getUserSSOProviders).not.toHaveBeenCalled(); + expect(mockBetterAuthClient.listAccounts).not.toHaveBeenCalled(); }); - it('should fetch providers from NextAuth when BetterAuth is disabled', async () => { - enableBetterAuth.value = false; - const mockProviders = [ - { provider: 'github', email: 'test@example.com', providerAccountId: '123' }, - ]; - mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders); - - const { result } = renderHook(() => useUserStore()); - - await act(async () => { - await result.current.fetchAuthProviders(); - }); - - expect(mockUserService.getUserSSOProviders).toHaveBeenCalled(); - expect(result.current.isLoadedAuthProviders).toBe(true); - expect(result.current.authProviders).toEqual(mockProviders); - }); - - it('should fetch providers from BetterAuth when enabled', async () => { - enableBetterAuth.value = true; + it('should fetch providers from BetterAuth', async () => { mockBetterAuthClient.listAccounts.mockResolvedValueOnce({ data: [ { providerId: 'github', accountId: 'gh-123' }, @@ -260,8 +155,7 @@ describe('createAuthSlice', () => { }); it('should handle fetch error gracefully', async () => { - enableBetterAuth.value = false; - mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Network error')); + mockBetterAuthClient.listAccounts.mockRejectedValueOnce(new Error('Network error')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -277,12 +171,13 @@ describe('createAuthSlice', () => { }); describe('refreshAuthProviders', () => { - it('should refresh providers from NextAuth', async () => { - enableBetterAuth.value = false; - const mockProviders = [ - { provider: 'google', email: 'user@gmail.com', providerAccountId: 'g-1' }, - ]; - mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders); + it('should refresh providers from BetterAuth', async () => { + mockBetterAuthClient.listAccounts.mockResolvedValueOnce({ + data: [{ providerId: 'google', accountId: 'g-1' }], + }); + mockBetterAuthClient.accountInfo.mockResolvedValueOnce({ + data: { user: { email: 'user@gmail.com' } }, + }); const { result } = renderHook(() => useUserStore()); @@ -290,13 +185,14 @@ describe('createAuthSlice', () => { await result.current.refreshAuthProviders(); }); - expect(mockUserService.getUserSSOProviders).toHaveBeenCalled(); - expect(result.current.authProviders).toEqual(mockProviders); + expect(mockBetterAuthClient.listAccounts).toHaveBeenCalled(); + expect(result.current.authProviders).toEqual([ + { provider: 'google', email: 'user@gmail.com', providerAccountId: 'g-1' }, + ]); }); it('should handle refresh error gracefully', async () => { - enableBetterAuth.value = false; - mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Refresh failed')); + mockBetterAuthClient.listAccounts.mockRejectedValueOnce(new Error('Refresh failed')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/store/user/slices/auth/action.ts b/src/store/user/slices/auth/action.ts index e3e98d1d64..60c14c2212 100644 --- a/src/store/user/slices/auth/action.ts +++ b/src/store/user/slices/auth/action.ts @@ -1,9 +1,6 @@ import { type SSOProvider } from '@lobechat/types'; import { type StateCreator } from 'zustand/vanilla'; -import { enableBetterAuth, enableNextAuth } from '@/envs/auth'; -import { userService } from '@/services/user'; - import type { UserStore } from '../../store'; interface AuthProvidersData { @@ -31,32 +28,26 @@ export interface UserAuthAction { } const fetchAuthProvidersData = async (): Promise => { - if (enableBetterAuth) { - const { accountInfo, listAccounts } = await import('@/libs/better-auth/auth-client'); - const result = await listAccounts(); - const accounts = result.data || []; - const hasPasswordAccount = accounts.some((account) => account.providerId === 'credential'); - const providers = await Promise.all( - accounts - .filter((account) => account.providerId !== 'credential') - .map(async (account) => { - // In theory, the id_token could be decrypted from the accounts table, but I found that better-auth on GitHub does not save the id_token - const info = await accountInfo({ - query: { accountId: account.accountId }, - }); - return { - email: info.data?.user?.email ?? undefined, - provider: account.providerId, - providerAccountId: account.accountId, - }; - }), - ); - return { hasPasswordAccount, providers }; - } - - // Fallback for NextAuth - const providers = await userService.getUserSSOProviders(); - return { hasPasswordAccount: false, providers }; + const { accountInfo, listAccounts } = await import('@/libs/better-auth/auth-client'); + const result = await listAccounts(); + const accounts = result.data || []; + const hasPasswordAccount = accounts.some((account) => account.providerId === 'credential'); + const providers = await Promise.all( + accounts + .filter((account) => account.providerId !== 'credential') + .map(async (account) => { + // In theory, the id_token could be decrypted from the accounts table, but I found that better-auth on GitHub does not save the id_token + const info = await accountInfo({ + query: { accountId: account.accountId }, + }); + return { + email: info.data?.user?.email ?? undefined, + provider: account.providerId, + providerAccountId: account.accountId, + }; + }), + ); + return { hasPasswordAccount, providers }; }; export const createAuthSlice: StateCreator< @@ -78,50 +69,26 @@ export const createAuthSlice: StateCreator< } }, logout: async () => { - if (enableBetterAuth) { - const { signOut } = await import('@/libs/better-auth/auth-client'); - await signOut({ - fetchOptions: { - onSuccess: () => { - // Use window.location.href to trigger a full page reload - // This ensures all client-side state (React, Zustand, cache) is cleared - window.location.href = '/signin'; - }, + const { signOut } = await import('@/libs/better-auth/auth-client'); + await signOut({ + fetchOptions: { + onSuccess: () => { + // Use window.location.href to trigger a full page reload + // This ensures all client-side state (React, Zustand, cache) is cleared + window.location.href = '/signin'; }, - }); - - return; - } - - if (enableNextAuth) { - const { signOut } = await import('next-auth/react'); - signOut(); - } + }, + }); }, openLogin: async () => { - // Skip if already on a Better Auth login page (/signin, /signup) + // Skip if already on a login page (/signin, /signup) const pathname = location.pathname; if (pathname.startsWith('/signin') || pathname.startsWith('/signup')) { return; } - if (enableBetterAuth) { - const currentUrl = location.toString(); - window.location.href = `/signin?callbackUrl=${encodeURIComponent(currentUrl)}`; - - return; - } - - if (enableNextAuth) { - const { signIn } = await import('next-auth/react'); - // Check if only one provider is available - const providers = get()?.oAuthSSOProviders; - if (providers && providers.length === 1) { - signIn(providers[0]); - return; - } - signIn(); - } + const currentUrl = location.toString(); + window.location.href = `/signin?callbackUrl=${encodeURIComponent(currentUrl)}`; }, refreshAuthProviders: async () => { try { diff --git a/src/store/user/slices/auth/initialState.ts b/src/store/user/slices/auth/initialState.ts index f71872f37b..9ba2da2e42 100644 --- a/src/store/user/slices/auth/initialState.ts +++ b/src/store/user/slices/auth/initialState.ts @@ -1,4 +1,3 @@ -import { type Session, type User } from '@auth/core/types'; import { type SSOProvider } from '@lobechat/types'; import { type LobeUser } from '@/types/user'; @@ -13,8 +12,6 @@ export interface UserAuthState { isLoadedAuthProviders?: boolean; isSignedIn?: boolean; - nextSession?: Session; - nextUser?: User; oAuthSSOProviders?: string[]; user?: LobeUser; } diff --git a/src/store/user/slices/auth/selectors.ts b/src/store/user/slices/auth/selectors.ts index 174cec7efd..ac8fa65d38 100644 --- a/src/store/user/slices/auth/selectors.ts +++ b/src/store/user/slices/auth/selectors.ts @@ -1,7 +1,6 @@ import { type LobeUser, type SSOProvider } from '@lobechat/types'; import { t } from 'i18next'; -import { enableBetterAuth, enableNextAuth } from '@/envs/auth'; import type { UserStore } from '@/store/user'; const nickName = (s: UserStore) => { @@ -37,6 +36,4 @@ export const authSelectors = { isLoadedAuthProviders: (s: UserStore) => s.isLoadedAuthProviders ?? false, isLogin: (s: UserStore) => s.isSignedIn, isLoginWithAuth: (s: UserStore) => s.isSignedIn, - isLoginWithBetterAuth: (s: UserStore): boolean => (s.isSignedIn && enableBetterAuth) || false, - isLoginWithNextAuth: (s: UserStore): boolean => (s.isSignedIn && !!enableNextAuth) || false, }; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts deleted file mode 100644 index d2276612e0..0000000000 --- a/src/types/next-auth.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type DefaultSession } from 'next-auth'; - -declare module 'next-auth' { - /** - * Returned by `useSession`, `auth`, contains information about the active session. - */ - interface Session { - user: { - firstName?: string; - } & DefaultSession['user']; - } - interface User { - providerAccountId?: string; - } - /** - * More types can be extends here - * ref: https://authjs.dev/getting-started/typescript - */ -} - -declare module '@auth/core/jwt' { - /** Returned by the `jwt` callback and `auth`, when using JWT sessions */ - interface JWT { - userId: string; - } -} diff --git a/tests/setup.ts b/tests/setup.ts index eaa674a938..8d5627454e 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -24,6 +24,16 @@ vi.mock('@lobehub/analytics/react', () => ({ }), })); +// Global mock for @/auth to avoid better-auth validator module issue in tests +// The validator package has ESM resolution issues in Vitest environment +vi.mock('@/auth', () => ({ + auth: { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + }, +})); + // node runtime if (typeof window === 'undefined') { // test with polyfill crypto