♻️ refactor(auth): remove NEXT_PUBLIC_AUTH_URL env variable (#11658)

This commit is contained in:
YuTengjing
2026-01-21 11:51:46 +08:00
committed by GitHub
parent a8b042f406
commit c0f9875195
19 changed files with 135 additions and 124 deletions

View File

@@ -307,9 +307,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Shared between Better-Auth and Next-Auth
# AUTH_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Auth URL (accessible from browser, optional if same domain)
# NEXT_PUBLIC_AUTH_URL=http://localhost:3210
# 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

View File

@@ -43,9 +43,6 @@ NEXT_PUBLIC_ENABLE_BETTER_AUTH=1
# Better Auth secret for JWT signing (generate with: openssl rand -base64 32)
AUTH_SECRET=${UNSAFE_SECRET}
# Authentication URL
NEXT_PUBLIC_AUTH_URL=${APP_URL}
# SSO providers configuration - using Casdoor for development
AUTH_SSO_PROVIDERS=casdoor

View File

@@ -189,8 +189,7 @@ ENV KEY_VAULTS_SECRET="" \
# Better Auth
ENV AUTH_SECRET="" \
AUTH_SSO_PROVIDERS="" \
NEXT_PUBLIC_AUTH_URL=""
AUTH_SSO_PROVIDERS=""
# Clerk
ENV CLERK_SECRET_KEY="" \

View File

@@ -39,12 +39,11 @@ By setting the environment variables `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CL
To enable Better Auth in LobeChat, set the following environment variables:
| Environment Variable | Type | Description |
| -------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | Required | Set to `1` to enable Better Auth service |
| `AUTH_SECRET` | Required | Key used to encrypt session tokens. Generate using: `openssl rand -base64 32` |
| `NEXT_PUBLIC_AUTH_URL` | Required | The browser-accessible base URL for Better Auth (e.g., `http://localhost:3010`, `https://lobechat.com`). Optional for Vercel deployments (auto-detected from `VERCEL_URL`) |
| `AUTH_SSO_PROVIDERS` | Optional | Comma-separated list of enabled SSO providers, e.g., `google,github,microsoft` |
| Environment Variable | Type | Description |
| -------------------------------- | -------- | ----------------------------------------------------------------------------- |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | Required | Set to `1` to enable Better Auth service |
| `AUTH_SECRET` | Required | Key used to encrypt session tokens. Generate using: `openssl rand -base64 32` |
| `AUTH_SSO_PROVIDERS` | Optional | Comma-separated list of enabled SSO providers, e.g., `google,github,microsoft`|
<Callout type={'error'}>
**Important**: Better Auth is currently only suitable for **fresh deployments**. If you are already using NextAuth or Clerk and have existing user data in your database, **do not switch to Better Auth yet**, otherwise existing users will not be able to log in.

View File

@@ -37,12 +37,11 @@ LobeChat 与 Clerk 做了深度集成,能够为用户提供一个更加安全
要在 LobeChat 中启用 Better Auth请设置以下环境变量
| 环境变量 | 类型 | 描述 |
| -------------------------------- | -- | ---------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | 必选 | 设置为 `1` 以启用 Better Auth 服务 |
| `AUTH_SECRET` | 必选 | 用于加密会话令牌的密钥。使用以下命令生成:`openssl rand -base64 32` |
| `NEXT_PUBLIC_AUTH_URL` | 选 | 浏览器可访问的 Better Auth 基础 URL例如 `http://localhost:3010`、`https://lobechat.com`。Vercel 部署时可选(会自动从 `VERCEL_URL` 获取) |
| `AUTH_SSO_PROVIDERS` | 可选 | 启用的 SSO 提供商列表,以逗号分隔,例如 `google,github,microsoft` |
| 环境变量 | 类型 | 描述 |
| -------------------------------- | -- | ----------------------------------------------------------- |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | 必选 | 设置为 `1` 以启用 Better Auth 服务 |
| `AUTH_SECRET` | 必选 | 用于加密会话令牌的密钥。使用以下命令生成:`openssl rand -base64 32` |
| `AUTH_SSO_PROVIDERS` | 选 | 启用的 SSO 提供商列表,以逗号分隔,例如 `google,github,microsoft` |
<Callout type={'error'}>
**重要提示**Better Auth 目前仅适用于**全新部署**的场景。如果你已经使用 NextAuth 或 Clerk 并且数据库中存在用户数据,**请暂时不要切换到 Better Auth**,否则现有用户将无法登录。

View File

@@ -34,13 +34,6 @@ LobeChat provides a complete authentication service capability when deployed. Th
- Default: `-`
- Example: `Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
#### `NEXT_PUBLIC_AUTH_URL`
- Type: Optional
- Description: The URL accessible from the browser for Better Auth callbacks. Only set this if the default generated URL is incorrect.
- Default: `-`
- Example: `https://example.com`
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
- Type: Optional

View File

@@ -32,13 +32,6 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
- 默认值:`-`
- 示例:`Tfhi2t2pelSMEA8eaV61KaqPNEndFFdMIxDaJnS1CUI=`
#### `NEXT_PUBLIC_AUTH_URL`
- 类型:可选
- 描述:浏览器可访问的 Better Auth 回调 URL。仅在默认生成的 URL 不正确时设置。
- 默认值:`-`
- 示例:`https://example.com`
#### `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION`
- 类型:可选

View File

@@ -51,10 +51,10 @@ const printEnvInfo = () => {
// Auth-related env vars
console.log('\n Auth Environment Variables:');
console.log(` NEXT_PUBLIC_AUTH_URL: ${process.env.NEXT_PUBLIC_AUTH_URL ?? '(not set)'}`);
console.log(` NEXTAUTH_URL: ${process.env.NEXTAUTH_URL ?? '(not set)'}`);
console.log(` APP_URL: ${process.env.APP_URL ?? '(not set)'}`);
console.log(` VERCEL_URL: ${process.env.VERCEL_URL ?? '(not set)'}`);
console.log(` VERCEL_BRANCH_URL: ${process.env.VERCEL_BRANCH_URL ?? '(not set)'}`);
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'}`);

View File

@@ -82,3 +82,84 @@ describe('getServerConfig', () => {
});
});
});
describe('APP_URL fallback', () => {
beforeEach(() => {
vi.resetModules();
// Clean up all related env vars
delete process.env.APP_URL;
delete process.env.VERCEL;
delete process.env.VERCEL_ENV;
delete process.env.VERCEL_URL;
delete process.env.VERCEL_BRANCH_URL;
delete process.env.VERCEL_PROJECT_PRODUCTION_URL;
});
it('should use APP_URL when explicitly set', async () => {
process.env.APP_URL = 'https://custom-app.com';
process.env.VERCEL = '1';
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('https://custom-app.com');
});
describe('Vercel environment', () => {
it('should use VERCEL_PROJECT_PRODUCTION_URL in production', async () => {
process.env.VERCEL = '1';
process.env.VERCEL_ENV = 'production';
process.env.VERCEL_PROJECT_PRODUCTION_URL = 'lobechat.vercel.app';
process.env.VERCEL_BRANCH_URL = 'lobechat-git-main-org.vercel.app';
process.env.VERCEL_URL = 'lobechat-abc123.vercel.app';
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('https://lobechat.vercel.app');
});
it('should use VERCEL_BRANCH_URL in preview environment', async () => {
process.env.VERCEL = '1';
process.env.VERCEL_ENV = 'preview';
process.env.VERCEL_BRANCH_URL = 'lobechat-git-feature-org.vercel.app';
process.env.VERCEL_URL = 'lobechat-abc123.vercel.app';
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('https://lobechat-git-feature-org.vercel.app');
});
it('should fallback to VERCEL_URL when VERCEL_BRANCH_URL is not set', async () => {
process.env.VERCEL = '1';
process.env.VERCEL_ENV = 'preview';
process.env.VERCEL_URL = 'lobechat-abc123.vercel.app';
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('https://lobechat-abc123.vercel.app');
});
});
describe('local environment', () => {
it('should use localhost:3010 in development', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('http://localhost:3010');
});
it('should use localhost:3210 in non-development', async () => {
vi.stubEnv('NODE_ENV', 'test');
const { getAppConfig } = await import('../app');
const config = getAppConfig();
expect(config.APP_URL).toBe('http://localhost:3210');
});
});
});

View File

@@ -12,12 +12,24 @@ declare global {
}
const isInVercel = process.env.VERCEL === '1';
const vercelUrl = `https://${process.env.VERCEL_URL}`;
// Vercel URL fallback order (by stability):
// 1. VERCEL_PROJECT_PRODUCTION_URL - project level, most stable
// 2. VERCEL_BRANCH_URL - branch level, stable across deployments on same branch
// 3. VERCEL_URL - deployment level, changes every deployment
const getVercelUrl = () => {
if (process.env.VERCEL_ENV === 'production' && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
}
if (process.env.VERCEL_BRANCH_URL) {
return `https://${process.env.VERCEL_BRANCH_URL}`;
}
return `https://${process.env.VERCEL_URL}`;
};
const APP_URL = process.env.APP_URL
? process.env.APP_URL
: isInVercel
? vercelUrl
? getVercelUrl()
: process.env.NODE_ENV === 'development'
? 'http://localhost:3010'
: 'http://localhost:3210';

View File

@@ -44,17 +44,4 @@ describe('getAuthConfig fallbacks', () => {
expect(config.AUTH_SECRET).toBe('nextauth-secret');
});
it('should fall back to NEXTAUTH_URL origin when NEXT_PUBLIC_AUTH_URL is empty string', () => {
process.env.NEXT_PUBLIC_AUTH_URL = '';
process.env.NEXTAUTH_URL = 'https://example.com/api/auth';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error - allow overriding for test
globalThis.window = undefined;
const config = getAuthConfig();
expect(config.NEXT_PUBLIC_AUTH_URL).toBe('https://example.com');
});
});

View File

@@ -2,43 +2,6 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
/**
* Resolve public auth URL with compatibility fallbacks for NextAuth and Vercel deployments.
*/
const resolvePublicAuthUrl = () => {
if (process.env.NEXT_PUBLIC_AUTH_URL) return process.env.NEXT_PUBLIC_AUTH_URL;
if (process.env.NEXTAUTH_URL) {
try {
return new URL(process.env.NEXTAUTH_URL).origin;
} catch {
// ignore invalid NEXTAUTH_URL
}
}
if (process.env.APP_URL) {
try {
return new URL(process.env.APP_URL).origin;
} catch {
// ignore invalid APP_URL
}
}
if (process.env.VERCEL_URL) {
try {
const normalizedVercelUrl = process.env.VERCEL_URL.startsWith('http')
? process.env.VERCEL_URL
: `https://${process.env.VERCEL_URL}`;
return new URL(normalizedVercelUrl).origin;
} catch {
// ignore invalid Vercel URL
}
}
return undefined;
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
@@ -50,7 +13,6 @@ declare global {
// ===== Auth (shared by Better Auth / Next Auth) ===== //
AUTH_SECRET?: string;
NEXT_PUBLIC_AUTH_URL?: string;
AUTH_EMAIL_VERIFICATION?: string;
ENABLE_MAGIC_LINK?: string;
AUTH_SSO_PROVIDERS?: string;
@@ -180,7 +142,6 @@ export const getAuthConfig = () => {
// ---------------------------------- better auth ----------------------------------
NEXT_PUBLIC_ENABLE_BETTER_AUTH: z.boolean().optional(),
NEXT_PUBLIC_AUTH_URL: z.string().optional(),
// ---------------------------------- next auth ----------------------------------
NEXT_PUBLIC_ENABLE_NEXT_AUTH: z.boolean().optional(),
@@ -310,8 +271,6 @@ export const getAuthConfig = () => {
// ---------------------------------- better auth ----------------------------------
NEXT_PUBLIC_ENABLE_BETTER_AUTH: process.env.NEXT_PUBLIC_ENABLE_BETTER_AUTH === '1',
// Fallback to NEXTAUTH_URL origin or Vercel deployment domain for seamless migration from next-auth
NEXT_PUBLIC_AUTH_URL: resolvePublicAuthUrl(),
// Fallback to NEXT_PUBLIC_* for seamless migration
AUTH_EMAIL_VERIFICATION:
process.env.AUTH_EMAIL_VERIFICATION === '1' ||

View File

@@ -7,9 +7,6 @@ import {
import { createAuthClient } from 'better-auth/react';
import type { auth } from '@/auth';
import { getAuthConfig } from '@/envs/auth';
const { NEXT_PUBLIC_AUTH_URL } = getAuthConfig();
export const {
linkSocial,
@@ -24,12 +21,6 @@ export const {
unlinkAccount,
useSession,
} = createAuthClient({
/** The base URL of the server (optional if you're using the same domain) */
...(NEXT_PUBLIC_AUTH_URL
? {
baseURL: NEXT_PUBLIC_AUTH_URL,
}
: {}),
plugins: [
adminClient(),
inferAdditionalFields<typeof auth>(),

View File

@@ -14,6 +14,7 @@ import { admin, emailOTP, genericOAuth, magicLink } from 'better-auth/plugins';
import { type BetterAuthPlugin } from 'better-auth/types';
import { businessEmailValidator } from '@/business/server/better-auth';
import { appEnv } from '@/envs/app';
import { authEnv } from '@/envs/auth';
import {
getMagicLinkEmailTemplate,
@@ -32,13 +33,13 @@ import { UserService } from '@/server/services/user';
const VERIFICATION_LINK_EXPIRES_IN = 3600;
/**
* Safely extract hostname from AUTH_URL for passkey rpID.
* Returns undefined if AUTH_URL is not set (e.g., in e2e tests).
* Safely extract hostname from APP_URL for passkey rpID.
* Returns undefined if APP_URL is not set (e.g., in e2e tests).
*/
const getPasskeyRpID = (): string | undefined => {
if (!authEnv.NEXT_PUBLIC_AUTH_URL) return undefined;
if (!appEnv.APP_URL) return undefined;
try {
return new URL(authEnv.NEXT_PUBLIC_AUTH_URL).hostname;
return new URL(appEnv.APP_URL).hostname;
} catch {
return undefined;
}
@@ -46,14 +47,15 @@ const getPasskeyRpID = (): string | undefined => {
/**
* Get passkey origins array.
* Returns undefined if AUTH_URL is not set (e.g., in e2e tests).
* Returns undefined if APP_URL is not set (e.g., in e2e tests).
*/
const getPasskeyOrigins = (): string[] | undefined => {
if (!authEnv.NEXT_PUBLIC_AUTH_URL) return undefined;
return [
// Web origin
authEnv.NEXT_PUBLIC_AUTH_URL,
];
if (!appEnv.APP_URL) return undefined;
try {
return [new URL(appEnv.APP_URL).origin];
} catch {
return undefined;
}
};
const MAGIC_LINK_EXPIRES_IN = 900;
// OTP expiration time (in seconds) - 5 minutes for mobile OTP verification
@@ -81,8 +83,7 @@ export function defineConfig(customOptions: CustomBetterAuthOptions) {
},
},
// Use renamed env vars (fallback to next-auth vars is handled in src/envs/auth.ts)
baseURL: authEnv.NEXT_PUBLIC_AUTH_URL,
baseURL: appEnv.APP_URL,
secret: authEnv.AUTH_SECRET,
trustedOrigins: getTrustedOrigins(enabledSSOProviders),

View File

@@ -1,6 +1,7 @@
import type { GenericOAuthConfig } from 'better-auth/plugins';
import type { SocialProviders } from 'better-auth/social-providers';
import { appEnv } from '@/envs/app';
import { authEnv } from '@/envs/auth';
import { BUILTIN_BETTER_AUTH_PROVIDERS } from '@/libs/better-auth/constants';
import { parseSSOProviders } from '@/libs/better-auth/utils/server';
@@ -106,7 +107,7 @@ export const initBetterAuthSSOProviders = () => {
if (config) {
// the generic oidc callback url is /api/auth/oauth2/callback/{providerId}
// different from builtin providers' /api/auth/callback/{providerId}
config.redirectURI = `${authEnv.NEXT_PUBLIC_AUTH_URL || ''}/api/auth/callback/${definition.id}`;
config.redirectURI = `${appEnv.APP_URL}/api/auth/callback/${definition.id}`;
genericOAuthProviders.push(config);
}
}

View File

@@ -1,3 +1,4 @@
import { appEnv } from '@/envs/app';
import { authEnv } from '@/envs/auth';
import { getRedisConfig } from '@/envs/redis';
import { initializeRedis, isRedisEnabled } from '@/libs/redis';
@@ -48,8 +49,7 @@ export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
}
const defaults = [
authEnv.NEXT_PUBLIC_AUTH_URL,
normalizeOrigin(process.env.APP_URL),
normalizeOrigin(appEnv.APP_URL),
normalizeOrigin(process.env.VERCEL_BRANCH_URL),
normalizeOrigin(process.env.VERCEL_URL),
MOBILE_APP_SCHEME,

View File

@@ -237,9 +237,8 @@ export function defineConfig() {
// ref: https://authjs.dev/getting-started/session-management/protecting
if (isProtected) {
logNextAuth('Request a protected route, redirecting to sign-in page');
const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
const nextLoginUrl = new URL('/next-auth/signin', authUrl);
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) {
@@ -325,9 +324,8 @@ export function defineConfig() {
// If request a protected route, redirect to sign-in page
if (isProtected) {
logBetterAuth('Request a protected route, redirecting to sign-in page');
const authUrl = authEnv.NEXT_PUBLIC_AUTH_URL;
const callbackUrl = `${authUrl}${req.nextUrl.pathname}${req.nextUrl.search}`;
const signInUrl = new URL('/signin', authUrl);
const callbackUrl = `${appEnv.APP_URL}${req.nextUrl.pathname}${req.nextUrl.search}`;
const signInUrl = new URL('/signin', appEnv.APP_URL);
signInUrl.searchParams.set('callbackUrl', callbackUrl);
const hl = req.nextUrl.searchParams.get('hl');
if (hl) {

View File

@@ -1,3 +1,6 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentRuntimeService } from './AgentRuntimeService';
@@ -166,7 +169,7 @@ describe('AgentRuntimeService', () => {
it('should initialize with default base URL', () => {
delete process.env.AGENT_RUNTIME_BASE_URL;
const newService = new AgentRuntimeService(mockDb, mockUserId);
expect((newService as any).baseURL).toBe('http://localhost:3010/api/agent');
expect((newService as any).baseURL).toBe('http://localhost:3210/api/agent');
});
it('should initialize with custom base URL from environment', () => {

View File

@@ -10,6 +10,7 @@ import urlJoin from 'url-join';
import { MessageModel } from '@/database/models/message';
import { type LobeChatDatabase } from '@/database/type';
import { appEnv } from '@/envs/app';
import {
AgentRuntimeCoordinator,
type AgentRuntimeCoordinatorOptions,
@@ -126,7 +127,7 @@ export class AgentRuntimeService {
private stepCallbacks: Map<string, StepLifecycleCallbacks> = new Map();
private get baseURL() {
const baseUrl =
process.env.AGENT_RUNTIME_BASE_URL || process.env.APP_URL || 'http://localhost:3010';
process.env.AGENT_RUNTIME_BASE_URL || appEnv.APP_URL || 'http://localhost:3010';
return urlJoin(baseUrl, '/api/agent');
}