@lobehub/lobehub 2.0.0-next.161 → 2.0.0-next.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/changelog/v1.json +5 -0
- package/locales/ar/authError.json +40 -0
- package/locales/bg-BG/authError.json +40 -0
- package/locales/de-DE/authError.json +40 -0
- package/locales/en-US/authError.json +40 -0
- package/locales/es-ES/authError.json +40 -0
- package/locales/fa-IR/authError.json +40 -0
- package/locales/fr-FR/authError.json +40 -0
- package/locales/it-IT/authError.json +40 -0
- package/locales/ja-JP/authError.json +40 -0
- package/locales/ko-KR/authError.json +40 -0
- package/locales/nl-NL/authError.json +40 -0
- package/locales/pl-PL/authError.json +40 -0
- package/locales/pt-BR/authError.json +40 -0
- package/locales/ru-RU/authError.json +40 -0
- package/locales/tr-TR/authError.json +40 -0
- package/locales/vi-VN/authError.json +40 -0
- package/locales/zh-CN/authError.json +40 -0
- package/locales/zh-CN/setting.json +2 -2
- package/locales/zh-TW/authError.json +40 -0
- package/package.json +1 -1
- package/src/app/[variants]/(auth)/auth-error/page.tsx +59 -0
- package/src/auth.ts +13 -48
- package/src/envs/redis.ts +1 -1
- package/src/libs/better-auth/utils/config.ts +91 -0
- package/src/libs/redis/manager.ts +5 -1
- package/src/libs/redis/redis.test.ts +1 -1
- package/src/libs/redis/upstash.test.ts +9 -5
- package/src/libs/redis/upstash.ts +44 -20
- package/src/locales/default/authError.ts +40 -0
- package/src/locales/default/index.ts +2 -0
- package/src/proxy.ts +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions": {
|
|
3
|
+
"discord": "Geri bildirim için Discord'a git",
|
|
4
|
+
"home": "Ana sayfaya dön",
|
|
5
|
+
"retry": "Yeniden giriş yap"
|
|
6
|
+
},
|
|
7
|
+
"codes": {
|
|
8
|
+
"ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "Bu hesap başka bir kullanıcıya zaten bağlı",
|
|
9
|
+
"ACCOUNT_NOT_FOUND": "İlgili hesap bulunamadı",
|
|
10
|
+
"CREDENTIAL_ACCOUNT_NOT_FOUND": "Kimlik bilgisi hesabı mevcut değil",
|
|
11
|
+
"EMAIL_CAN_NOT_BE_UPDATED": "Bu hesabın e-posta adresi değiştirilemez",
|
|
12
|
+
"EMAIL_NOT_VERIFIED": "Lütfen önce e-posta doğrulamasını tamamlayın",
|
|
13
|
+
"FAILED_TO_CREATE_SESSION": "Oturum oluşturulamadı",
|
|
14
|
+
"FAILED_TO_CREATE_USER": "Kullanıcı oluşturulamadı",
|
|
15
|
+
"FAILED_TO_GET_SESSION": "Oturum alınamadı",
|
|
16
|
+
"FAILED_TO_GET_USER_INFO": "Kullanıcı bilgileri alınamadı",
|
|
17
|
+
"FAILED_TO_UNLINK_LAST_ACCOUNT": "Son bağlı hesabın bağlantısı kaldırılamaz",
|
|
18
|
+
"FAILED_TO_UPDATE_USER": "Kullanıcı bilgileri güncellenemedi",
|
|
19
|
+
"ID_TOKEN_NOT_SUPPORTED": "Bu kimlik belirteci desteklenmiyor",
|
|
20
|
+
"INVALID_EMAIL": "Geçersiz e-posta formatı",
|
|
21
|
+
"INVALID_EMAIL_OR_PASSWORD": "E-posta veya şifre hatalı",
|
|
22
|
+
"INVALID_PASSWORD": "Geçersiz şifre formatı",
|
|
23
|
+
"INVALID_TOKEN": "Belirteç geçersiz veya süresi dolmuş",
|
|
24
|
+
"PASSWORD_TOO_LONG": "Şifre çok uzun",
|
|
25
|
+
"PASSWORD_TOO_SHORT": "Şifre çok kısa",
|
|
26
|
+
"PROVIDER_NOT_FOUND": "İlgili kimlik sağlayıcısı bulunamadı",
|
|
27
|
+
"RATE_LIMIT_EXCEEDED": "Çok fazla istek gönderildi, lütfen daha sonra tekrar deneyin",
|
|
28
|
+
"SESSION_EXPIRED": "Oturum süresi doldu, lütfen yeniden giriş yapın",
|
|
29
|
+
"SOCIAL_ACCOUNT_ALREADY_LINKED": "Bu sosyal medya hesabı başka bir kullanıcıya bağlı",
|
|
30
|
+
"UNEXPECTED_ERROR": "Bilinmeyen bir hata oluştu, lütfen tekrar deneyin",
|
|
31
|
+
"UNKNOWN": "Bilinmeyen bir hata oluştu, lütfen tekrar deneyin veya destekle iletişime geçin",
|
|
32
|
+
"USER_ALREADY_EXISTS": "Kullanıcı zaten mevcut",
|
|
33
|
+
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Bu e-posta zaten kullanılıyor, lütfen başka bir e-posta deneyin",
|
|
34
|
+
"USER_ALREADY_HAS_PASSWORD": "Bu hesap zaten bir şifreye sahip",
|
|
35
|
+
"USER_BANNED": "Bu kullanıcı engellenmiş",
|
|
36
|
+
"USER_EMAIL_NOT_FOUND": "İlgili e-posta bulunamadı",
|
|
37
|
+
"USER_NOT_FOUND": "Kullanıcı bulunamadı"
|
|
38
|
+
},
|
|
39
|
+
"title": "Kimlik doğrulama hatası"
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions": {
|
|
3
|
+
"discord": "Gửi phản hồi qua Discord",
|
|
4
|
+
"home": "Quay về trang chủ",
|
|
5
|
+
"retry": "Đăng nhập lại"
|
|
6
|
+
},
|
|
7
|
+
"codes": {
|
|
8
|
+
"ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "Tài khoản này đã được liên kết với người dùng khác",
|
|
9
|
+
"ACCOUNT_NOT_FOUND": "Không tìm thấy tài khoản tương ứng",
|
|
10
|
+
"CREDENTIAL_ACCOUNT_NOT_FOUND": "Tài khoản xác thực không tồn tại",
|
|
11
|
+
"EMAIL_CAN_NOT_BE_UPDATED": "Không thể thay đổi email của tài khoản hiện tại",
|
|
12
|
+
"EMAIL_NOT_VERIFIED": "Vui lòng xác minh email trước",
|
|
13
|
+
"FAILED_TO_CREATE_SESSION": "Tạo phiên làm việc thất bại",
|
|
14
|
+
"FAILED_TO_CREATE_USER": "Tạo người dùng thất bại",
|
|
15
|
+
"FAILED_TO_GET_SESSION": "Lấy thông tin phiên làm việc thất bại",
|
|
16
|
+
"FAILED_TO_GET_USER_INFO": "Lấy thông tin người dùng thất bại",
|
|
17
|
+
"FAILED_TO_UNLINK_LAST_ACCOUNT": "Không thể hủy liên kết tài khoản cuối cùng",
|
|
18
|
+
"FAILED_TO_UPDATE_USER": "Cập nhật thông tin người dùng thất bại",
|
|
19
|
+
"ID_TOKEN_NOT_SUPPORTED": "Mã định danh không được hỗ trợ",
|
|
20
|
+
"INVALID_EMAIL": "Định dạng email không hợp lệ",
|
|
21
|
+
"INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng",
|
|
22
|
+
"INVALID_PASSWORD": "Định dạng mật khẩu không hợp lệ",
|
|
23
|
+
"INVALID_TOKEN": "Mã xác thực không hợp lệ hoặc đã hết hạn",
|
|
24
|
+
"PASSWORD_TOO_LONG": "Mật khẩu quá dài",
|
|
25
|
+
"PASSWORD_TOO_SHORT": "Mật khẩu quá ngắn",
|
|
26
|
+
"PROVIDER_NOT_FOUND": "Không tìm thấy cấu hình nhà cung cấp xác thực",
|
|
27
|
+
"RATE_LIMIT_EXCEEDED": "Yêu cầu quá nhiều, vui lòng thử lại sau",
|
|
28
|
+
"SESSION_EXPIRED": "Phiên làm việc đã hết hạn, vui lòng đăng nhập lại",
|
|
29
|
+
"SOCIAL_ACCOUNT_ALREADY_LINKED": "Tài khoản mạng xã hội này đã được liên kết với người dùng khác",
|
|
30
|
+
"UNEXPECTED_ERROR": "Đã xảy ra lỗi không xác định, vui lòng thử lại",
|
|
31
|
+
"UNKNOWN": "Đã xảy ra lỗi không xác định, vui lòng thử lại hoặc liên hệ hỗ trợ",
|
|
32
|
+
"USER_ALREADY_EXISTS": "Người dùng đã tồn tại",
|
|
33
|
+
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email đã được sử dụng, vui lòng thử email khác",
|
|
34
|
+
"USER_ALREADY_HAS_PASSWORD": "Tài khoản này đã có mật khẩu",
|
|
35
|
+
"USER_BANNED": "Người dùng này đã bị cấm",
|
|
36
|
+
"USER_EMAIL_NOT_FOUND": "Không tìm thấy email tương ứng",
|
|
37
|
+
"USER_NOT_FOUND": "Không tìm thấy người dùng"
|
|
38
|
+
},
|
|
39
|
+
"title": "Lỗi xác thực"
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions": {
|
|
3
|
+
"discord": "前往 Discord 反馈",
|
|
4
|
+
"home": "返回首页",
|
|
5
|
+
"retry": "重新登录"
|
|
6
|
+
},
|
|
7
|
+
"codes": {
|
|
8
|
+
"ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "该账号已关联至其他用户",
|
|
9
|
+
"ACCOUNT_NOT_FOUND": "未找到对应账号",
|
|
10
|
+
"CREDENTIAL_ACCOUNT_NOT_FOUND": "凭证账号不存在",
|
|
11
|
+
"EMAIL_CAN_NOT_BE_UPDATED": "当前账号邮箱不可修改",
|
|
12
|
+
"EMAIL_NOT_VERIFIED": "请先完成邮箱验证",
|
|
13
|
+
"FAILED_TO_CREATE_SESSION": "创建会话失败",
|
|
14
|
+
"FAILED_TO_CREATE_USER": "创建用户失败",
|
|
15
|
+
"FAILED_TO_GET_SESSION": "获取会话失败",
|
|
16
|
+
"FAILED_TO_GET_USER_INFO": "获取用户信息失败",
|
|
17
|
+
"FAILED_TO_UNLINK_LAST_ACCOUNT": "无法解绑最后一个关联账号",
|
|
18
|
+
"FAILED_TO_UPDATE_USER": "更新用户信息失败",
|
|
19
|
+
"ID_TOKEN_NOT_SUPPORTED": "当前身份令牌不被支持",
|
|
20
|
+
"INVALID_EMAIL": "邮箱格式不正确",
|
|
21
|
+
"INVALID_EMAIL_OR_PASSWORD": "邮箱或密码错误",
|
|
22
|
+
"INVALID_PASSWORD": "密码格式无效",
|
|
23
|
+
"INVALID_TOKEN": "令牌无效或已过期",
|
|
24
|
+
"PASSWORD_TOO_LONG": "密码长度过长",
|
|
25
|
+
"PASSWORD_TOO_SHORT": "密码长度过短",
|
|
26
|
+
"PROVIDER_NOT_FOUND": "未找到对应的身份提供方配置",
|
|
27
|
+
"RATE_LIMIT_EXCEEDED": "请求过于频繁,请稍后再试",
|
|
28
|
+
"SESSION_EXPIRED": "会话已过期,请重新登录",
|
|
29
|
+
"SOCIAL_ACCOUNT_ALREADY_LINKED": "该社交账号已被其他用户绑定",
|
|
30
|
+
"UNEXPECTED_ERROR": "发生未知错误,请重试",
|
|
31
|
+
"UNKNOWN": "发生未知错误,请重试或联系支持",
|
|
32
|
+
"USER_ALREADY_EXISTS": "用户已存在",
|
|
33
|
+
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "邮箱已被使用,请尝试其他邮箱",
|
|
34
|
+
"USER_ALREADY_HAS_PASSWORD": "该账号已设置密码",
|
|
35
|
+
"USER_BANNED": "该用户已被封禁",
|
|
36
|
+
"USER_EMAIL_NOT_FOUND": "未找到对应邮箱",
|
|
37
|
+
"USER_NOT_FOUND": "未找到用户"
|
|
38
|
+
},
|
|
39
|
+
"title": "身份验证出错"
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions": {
|
|
3
|
+
"discord": "前往 Discord 回饋",
|
|
4
|
+
"home": "返回首頁",
|
|
5
|
+
"retry": "重新登入"
|
|
6
|
+
},
|
|
7
|
+
"codes": {
|
|
8
|
+
"ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER": "該帳號已綁定至其他使用者",
|
|
9
|
+
"ACCOUNT_NOT_FOUND": "找不到對應帳號",
|
|
10
|
+
"CREDENTIAL_ACCOUNT_NOT_FOUND": "憑證帳號不存在",
|
|
11
|
+
"EMAIL_CAN_NOT_BE_UPDATED": "目前帳號的電子郵件無法修改",
|
|
12
|
+
"EMAIL_NOT_VERIFIED": "請先完成電子郵件驗證",
|
|
13
|
+
"FAILED_TO_CREATE_SESSION": "建立會話失敗",
|
|
14
|
+
"FAILED_TO_CREATE_USER": "建立使用者失敗",
|
|
15
|
+
"FAILED_TO_GET_SESSION": "取得會話失敗",
|
|
16
|
+
"FAILED_TO_GET_USER_INFO": "取得使用者資訊失敗",
|
|
17
|
+
"FAILED_TO_UNLINK_LAST_ACCOUNT": "無法解除綁定最後一個關聯帳號",
|
|
18
|
+
"FAILED_TO_UPDATE_USER": "更新使用者資訊失敗",
|
|
19
|
+
"ID_TOKEN_NOT_SUPPORTED": "目前的身份令牌不被支援",
|
|
20
|
+
"INVALID_EMAIL": "電子郵件格式不正確",
|
|
21
|
+
"INVALID_EMAIL_OR_PASSWORD": "電子郵件或密碼錯誤",
|
|
22
|
+
"INVALID_PASSWORD": "密碼格式無效",
|
|
23
|
+
"INVALID_TOKEN": "令牌無效或已過期",
|
|
24
|
+
"PASSWORD_TOO_LONG": "密碼長度過長",
|
|
25
|
+
"PASSWORD_TOO_SHORT": "密碼長度過短",
|
|
26
|
+
"PROVIDER_NOT_FOUND": "找不到對應的身份提供者設定",
|
|
27
|
+
"RATE_LIMIT_EXCEEDED": "請求過於頻繁,請稍後再試",
|
|
28
|
+
"SESSION_EXPIRED": "會話已過期,請重新登入",
|
|
29
|
+
"SOCIAL_ACCOUNT_ALREADY_LINKED": "該社群帳號已被其他使用者綁定",
|
|
30
|
+
"UNEXPECTED_ERROR": "發生未知錯誤,請重試",
|
|
31
|
+
"UNKNOWN": "發生未知錯誤,請重試或聯絡支援",
|
|
32
|
+
"USER_ALREADY_EXISTS": "使用者已存在",
|
|
33
|
+
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "電子郵件已被使用,請嘗試其他電子郵件",
|
|
34
|
+
"USER_ALREADY_HAS_PASSWORD": "該帳號已設定密碼",
|
|
35
|
+
"USER_BANNED": "該使用者已被封鎖",
|
|
36
|
+
"USER_EMAIL_NOT_FOUND": "找不到對應的電子郵件",
|
|
37
|
+
"USER_NOT_FOUND": "找不到使用者"
|
|
38
|
+
},
|
|
39
|
+
"title": "身份驗證錯誤"
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.162",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { SiDiscord } from '@icons-pack/react-simple-icons';
|
|
4
|
+
import { Button, Icon } from '@lobehub/ui';
|
|
5
|
+
import { Result, Tag, Typography } from 'antd';
|
|
6
|
+
import { ShieldAlert } from 'lucide-react';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { parseAsString, useQueryState } from 'nuqs';
|
|
9
|
+
import { memo } from 'react';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
import { Flexbox } from 'react-layout-kit';
|
|
12
|
+
|
|
13
|
+
const DISCORD_URL = 'https://discord.gg/AYFPHvv2jT';
|
|
14
|
+
|
|
15
|
+
const normalizeErrorCode = (code?: string | null) =>
|
|
16
|
+
(code || 'UNKNOWN').trim().toUpperCase().replaceAll('-', '_');
|
|
17
|
+
|
|
18
|
+
const AuthErrorPage = memo(() => {
|
|
19
|
+
const { t } = useTranslation('authError');
|
|
20
|
+
const [error] = useQueryState('error', parseAsString);
|
|
21
|
+
|
|
22
|
+
const code = normalizeErrorCode(error);
|
|
23
|
+
const description = t(`codes.${code}`, { defaultValue: t('codes.UNKNOWN') });
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Result
|
|
27
|
+
extra={
|
|
28
|
+
<Flexbox align="center" gap={16}>
|
|
29
|
+
<Flexbox gap={12} horizontal justify="center" wrap="wrap">
|
|
30
|
+
<Link href="/signin">
|
|
31
|
+
<Button type="primary">{t('actions.retry')}</Button>
|
|
32
|
+
</Link>
|
|
33
|
+
<Link href="/">
|
|
34
|
+
<Button>{t('actions.home')}</Button>
|
|
35
|
+
</Link>
|
|
36
|
+
</Flexbox>
|
|
37
|
+
<Link href={DISCORD_URL} rel="noopener noreferrer" target="_blank">
|
|
38
|
+
<Button icon={<Icon icon={SiDiscord} />} type="text">
|
|
39
|
+
{t('actions.discord')}
|
|
40
|
+
</Button>
|
|
41
|
+
</Link>
|
|
42
|
+
</Flexbox>
|
|
43
|
+
}
|
|
44
|
+
icon={<Icon icon={ShieldAlert} />}
|
|
45
|
+
status="error"
|
|
46
|
+
subTitle={
|
|
47
|
+
<Flexbox align="center" gap={8}>
|
|
48
|
+
<Tag color="red">{error || 'UNKNOWN'}</Tag>
|
|
49
|
+
<Typography.Text type="secondary">{description}</Typography.Text>
|
|
50
|
+
</Flexbox>
|
|
51
|
+
}
|
|
52
|
+
title={t('title')}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
AuthErrorPage.displayName = 'AuthErrorPage';
|
|
58
|
+
|
|
59
|
+
export default AuthErrorPage;
|
package/src/auth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
|
2
2
|
import { createNanoId, idGenerator, serverDB } from '@lobechat/database';
|
|
3
|
-
import { betterAuth } from 'better-auth/minimal';
|
|
4
3
|
import { emailHarmony } from 'better-auth-harmony';
|
|
5
4
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
5
|
+
import { betterAuth } from 'better-auth/minimal';
|
|
6
6
|
import { admin, genericOAuth, magicLink } from 'better-auth/plugins';
|
|
7
7
|
|
|
8
8
|
import { authEnv } from '@/envs/auth';
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getVerificationEmailTemplate,
|
|
13
13
|
} from '@/libs/better-auth/email-templates';
|
|
14
14
|
import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
|
|
15
|
+
import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
|
|
15
16
|
import { parseSSOProviders } from '@/libs/better-auth/utils/server';
|
|
16
17
|
import { EmailService } from '@/server/services/email';
|
|
17
18
|
import { UserService } from '@/server/services/user';
|
|
@@ -21,55 +22,10 @@ import { UserService } from '@/server/services/user';
|
|
|
21
22
|
const VERIFICATION_LINK_EXPIRES_IN = 3600;
|
|
22
23
|
const MAGIC_LINK_EXPIRES_IN = 900;
|
|
23
24
|
const enableMagicLink = authEnv.NEXT_PUBLIC_ENABLE_MAGIC_LINK;
|
|
24
|
-
const APPLE_TRUSTED_ORIGIN = 'https://appleid.apple.com';
|
|
25
25
|
const enabledSSOProviders = parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
|
|
26
26
|
|
|
27
27
|
const { socialProviders, genericOAuthProviders } = initBetterAuthSSOProviders();
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Normalize a URL-like string to an origin with https fallback.
|
|
31
|
-
*/
|
|
32
|
-
const normalizeOrigin = (url?: string) => {
|
|
33
|
-
if (!url) return undefined;
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
37
|
-
|
|
38
|
-
return new URL(normalizedUrl).origin;
|
|
39
|
-
} catch {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Build trusted origins with env override and Vercel-aware defaults.
|
|
46
|
-
*/
|
|
47
|
-
const getTrustedOrigins = () => {
|
|
48
|
-
if (authEnv.AUTH_TRUSTED_ORIGINS) {
|
|
49
|
-
const originsFromEnv = authEnv.AUTH_TRUSTED_ORIGINS.split(',')
|
|
50
|
-
.map((item) => normalizeOrigin(item.trim()))
|
|
51
|
-
.filter(Boolean) as string[];
|
|
52
|
-
|
|
53
|
-
if (originsFromEnv.length > 0) return Array.from(new Set(originsFromEnv));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const defaults = [
|
|
57
|
-
authEnv.NEXT_PUBLIC_AUTH_URL,
|
|
58
|
-
normalizeOrigin(process.env.APP_URL),
|
|
59
|
-
normalizeOrigin(process.env.VERCEL_BRANCH_URL),
|
|
60
|
-
normalizeOrigin(process.env.VERCEL_URL),
|
|
61
|
-
].filter(Boolean) as string[];
|
|
62
|
-
|
|
63
|
-
const baseTrustedOrigins = defaults.length > 0 ? Array.from(new Set(defaults)) : undefined;
|
|
64
|
-
|
|
65
|
-
if (!enabledSSOProviders.includes('apple')) return baseTrustedOrigins;
|
|
66
|
-
|
|
67
|
-
const mergedOrigins = new Set(baseTrustedOrigins || []);
|
|
68
|
-
mergedOrigins.add(APPLE_TRUSTED_ORIGIN);
|
|
69
|
-
|
|
70
|
-
return Array.from(mergedOrigins);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
29
|
export const auth = betterAuth({
|
|
74
30
|
account: {
|
|
75
31
|
accountLinking: {
|
|
@@ -82,7 +38,7 @@ export const auth = betterAuth({
|
|
|
82
38
|
// Use renamed env vars (fallback to next-auth vars is handled in src/envs/auth.ts)
|
|
83
39
|
baseURL: authEnv.NEXT_PUBLIC_AUTH_URL,
|
|
84
40
|
secret: authEnv.AUTH_SECRET,
|
|
85
|
-
trustedOrigins: getTrustedOrigins(),
|
|
41
|
+
trustedOrigins: getTrustedOrigins(enabledSSOProviders),
|
|
86
42
|
|
|
87
43
|
emailAndPassword: {
|
|
88
44
|
autoSignIn: true,
|
|
@@ -118,10 +74,19 @@ export const auth = betterAuth({
|
|
|
118
74
|
});
|
|
119
75
|
},
|
|
120
76
|
},
|
|
121
|
-
|
|
77
|
+
onAPIError: {
|
|
78
|
+
errorURL: '/auth-error',
|
|
79
|
+
},
|
|
80
|
+
session: {
|
|
81
|
+
cookieCache: {
|
|
82
|
+
enabled: true,
|
|
83
|
+
maxAge: 10 * 60, // Cache duration in seconds
|
|
84
|
+
},
|
|
85
|
+
},
|
|
122
86
|
database: drizzleAdapter(serverDB, {
|
|
123
87
|
provider: 'pg',
|
|
124
88
|
}),
|
|
89
|
+
secondaryStorage: createSecondaryStorage(),
|
|
125
90
|
/**
|
|
126
91
|
* Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
|
|
127
92
|
* Endpoints like /get-session, /get-full-organization and many others benefit greatly from this feature,
|
package/src/envs/redis.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { authEnv } from '@/envs/auth';
|
|
2
|
+
import { getRedisConfig } from '@/envs/redis';
|
|
3
|
+
import { initializeRedis, isRedisEnabled } from '@/libs/redis';
|
|
4
|
+
|
|
5
|
+
const APPLE_TRUSTED_ORIGIN = 'https://appleid.apple.com';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a URL-like string to an origin with https fallback.
|
|
9
|
+
*/
|
|
10
|
+
export const normalizeOrigin = (url?: string) => {
|
|
11
|
+
if (!url) return undefined;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
|
|
15
|
+
|
|
16
|
+
return new URL(normalizedUrl).origin;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build trusted origins with env override and Vercel-aware defaults.
|
|
24
|
+
*/
|
|
25
|
+
export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
|
|
26
|
+
if (authEnv.AUTH_TRUSTED_ORIGINS) {
|
|
27
|
+
const originsFromEnv = authEnv.AUTH_TRUSTED_ORIGINS.split(',')
|
|
28
|
+
.map((item) => normalizeOrigin(item.trim()))
|
|
29
|
+
.filter(Boolean) as string[];
|
|
30
|
+
|
|
31
|
+
if (originsFromEnv.length > 0) return Array.from(new Set(originsFromEnv));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defaults = [
|
|
35
|
+
authEnv.NEXT_PUBLIC_AUTH_URL,
|
|
36
|
+
normalizeOrigin(process.env.APP_URL),
|
|
37
|
+
normalizeOrigin(process.env.VERCEL_BRANCH_URL),
|
|
38
|
+
normalizeOrigin(process.env.VERCEL_URL),
|
|
39
|
+
].filter(Boolean) as string[];
|
|
40
|
+
|
|
41
|
+
const baseTrustedOrigins = defaults.length > 0 ? Array.from(new Set(defaults)) : undefined;
|
|
42
|
+
|
|
43
|
+
if (!enabledSSOProviders.includes('apple')) return baseTrustedOrigins;
|
|
44
|
+
|
|
45
|
+
const mergedOrigins = new Set(baseTrustedOrigins || []);
|
|
46
|
+
mergedOrigins.add(APPLE_TRUSTED_ORIGIN);
|
|
47
|
+
|
|
48
|
+
return Array.from(mergedOrigins);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build Better Auth secondaryStorage backed by Redis.
|
|
53
|
+
* Uses the shared Redis manager to avoid duplicate connections and prefixes keys to prevent clashes.
|
|
54
|
+
*/
|
|
55
|
+
export const createSecondaryStorage = () => {
|
|
56
|
+
const redisConfig = getRedisConfig();
|
|
57
|
+
if (!isRedisEnabled(redisConfig)) return undefined;
|
|
58
|
+
|
|
59
|
+
const secondaryStorageKeyPrefix = 'better-auth:';
|
|
60
|
+
|
|
61
|
+
const buildKey = (key: string) => `${secondaryStorageKeyPrefix}${key}`;
|
|
62
|
+
|
|
63
|
+
const getRedisClient = async () => {
|
|
64
|
+
const redisClient = await initializeRedis(redisConfig);
|
|
65
|
+
if (!redisClient) {
|
|
66
|
+
throw new Error('Redis secondary storage is enabled but failed to initialize');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return redisClient;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
delete: async (key: string) => {
|
|
74
|
+
const redisClient = await getRedisClient();
|
|
75
|
+
await redisClient.del(buildKey(key));
|
|
76
|
+
},
|
|
77
|
+
get: async (key: string) => {
|
|
78
|
+
const redisClient = await getRedisClient();
|
|
79
|
+
return (await redisClient.get(buildKey(key))) ?? null;
|
|
80
|
+
},
|
|
81
|
+
set: async (key: string, value: string, ttl?: number) => {
|
|
82
|
+
const redisClient = await getRedisClient();
|
|
83
|
+
if (typeof ttl === 'number') {
|
|
84
|
+
await redisClient.set(buildKey(key), value, { ex: ttl });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await redisClient.set(buildKey(key), value);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
@@ -23,7 +23,11 @@ class RedisManager {
|
|
|
23
23
|
if (config.provider === 'redis') {
|
|
24
24
|
provider = new IoRedisRedisProvider(config);
|
|
25
25
|
} else if (config.provider === 'upstash') {
|
|
26
|
-
provider = new UpstashRedisProvider({
|
|
26
|
+
provider = new UpstashRedisProvider({
|
|
27
|
+
prefix: config.prefix,
|
|
28
|
+
token: config.token,
|
|
29
|
+
url: config.url,
|
|
30
|
+
});
|
|
27
31
|
} else {
|
|
28
32
|
throw new Error(`Unsupported redis provider: ${String((config as any).provider)}`);
|
|
29
33
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Read more here: https://github.com/capricorn86/happy-dom/issues/1042#issuecomment-3585851354
|
|
7
7
|
import { Buffer } from 'node:buffer';
|
|
8
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
|
|
10
10
|
import { UpstashConfig } from './types';
|
|
11
11
|
|
|
@@ -139,9 +139,9 @@ describe('mocked', () => {
|
|
|
139
139
|
await provider.hset(bufKey, 'field', 'value');
|
|
140
140
|
await provider.del(bufKey);
|
|
141
141
|
|
|
142
|
-
expect(mocks.mockSet).toHaveBeenCalledWith('buffer-key', 'value', undefined);
|
|
143
|
-
expect(mocks.mockHset).toHaveBeenCalledWith('buffer-key', { field: 'value' });
|
|
144
|
-
expect(mocks.mockDel).toHaveBeenCalledWith('buffer-key');
|
|
142
|
+
expect(mocks.mockSet).toHaveBeenCalledWith('mock:buffer-key', 'value', undefined);
|
|
143
|
+
expect(mocks.mockHset).toHaveBeenCalledWith('mock:buffer-key', { field: 'value' });
|
|
144
|
+
expect(mocks.mockDel).toHaveBeenCalledWith('mock:buffer-key');
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
it('passes set options through to upstash client', async () => {
|
|
@@ -149,6 +149,10 @@ describe('mocked', () => {
|
|
|
149
149
|
|
|
150
150
|
await provider.set('key', 'value', { ex: 10, nx: true, get: true });
|
|
151
151
|
|
|
152
|
-
expect(mocks.mockSet).toHaveBeenCalledWith('key', 'value', {
|
|
152
|
+
expect(mocks.mockSet).toHaveBeenCalledWith('mock:key', 'value', {
|
|
153
|
+
ex: 10,
|
|
154
|
+
nx: true,
|
|
155
|
+
get: true,
|
|
156
|
+
});
|
|
153
157
|
});
|
|
154
158
|
});
|
|
@@ -20,9 +20,28 @@ import {
|
|
|
20
20
|
export class UpstashRedisProvider implements BaseRedisProvider {
|
|
21
21
|
provider: 'upstash' = 'upstash';
|
|
22
22
|
private client: Redis;
|
|
23
|
+
private readonly prefix: string;
|
|
23
24
|
|
|
24
25
|
constructor(options: UpstashConfig | RedisConfigNodejs) {
|
|
25
|
-
|
|
26
|
+
const { prefix, ...clientOptions } = options as UpstashConfig & RedisConfigNodejs;
|
|
27
|
+
this.prefix = prefix ? `${prefix}:` : '';
|
|
28
|
+
this.client = new Redis(clientOptions as RedisConfigNodejs);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a fully qualified key assuming the input was already normalized.
|
|
33
|
+
* Avoids re-running normalization when callers have normalized keys (e.g. mset).
|
|
34
|
+
*/
|
|
35
|
+
private addPrefixToKey(normalizedKey: string) {
|
|
36
|
+
return `${this.prefix}${normalizedKey}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private buildKey(key: RedisKey) {
|
|
40
|
+
return this.addPrefixToKey(normalizeRedisKey(key));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private buildKeys(keys: RedisKey[]) {
|
|
44
|
+
return normalizeRedisKeys(keys).map((key) => `${this.prefix}${key}`);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
async initialize(): Promise<void> {
|
|
@@ -34,15 +53,11 @@ export class UpstashRedisProvider implements BaseRedisProvider {
|
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
async get(key: RedisKey): Promise<string | null> {
|
|
37
|
-
return this.client.get(
|
|
56
|
+
return this.client.get(this.buildKey(key));
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
async set(key: RedisKey, value: RedisValue, options?: SetOptions): Promise<RedisSetResult> {
|
|
41
|
-
const res = await this.client.set(
|
|
42
|
-
normalizeRedisKey(key),
|
|
43
|
-
value,
|
|
44
|
-
buildUpstashSetOptions(options),
|
|
45
|
-
);
|
|
60
|
+
const res = await this.client.set(this.buildKey(key), value, buildUpstashSetOptions(options));
|
|
46
61
|
if (Buffer.isBuffer(res)) {
|
|
47
62
|
return res.toString();
|
|
48
63
|
}
|
|
@@ -51,55 +66,64 @@ export class UpstashRedisProvider implements BaseRedisProvider {
|
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
async setex(key: RedisKey, seconds: number, value: RedisValue): Promise<'OK'> {
|
|
54
|
-
return this.client.setex(
|
|
69
|
+
return this.client.setex(this.buildKey(key), seconds, value);
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
async del(...keys: RedisKey[]): Promise<number> {
|
|
58
|
-
return this.client.del(...
|
|
73
|
+
return this.client.del(...this.buildKeys(keys));
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
async exists(...keys: RedisKey[]): Promise<number> {
|
|
62
|
-
return this.client.exists(...
|
|
77
|
+
return this.client.exists(...this.buildKeys(keys));
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
async expire(key: RedisKey, seconds: number): Promise<number> {
|
|
66
|
-
return this.client.expire(
|
|
81
|
+
return this.client.expire(this.buildKey(key), seconds);
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
async ttl(key: RedisKey): Promise<number> {
|
|
70
|
-
return this.client.ttl(
|
|
85
|
+
return this.client.ttl(this.buildKey(key));
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
async incr(key: RedisKey): Promise<number> {
|
|
74
|
-
return this.client.incr(
|
|
89
|
+
return this.client.incr(this.buildKey(key));
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
async decr(key: RedisKey): Promise<number> {
|
|
78
|
-
return this.client.decr(
|
|
93
|
+
return this.client.decr(this.buildKey(key));
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
async mget(...keys: RedisKey[]): Promise<(string | null)[]> {
|
|
82
|
-
return this.client.mget(...
|
|
97
|
+
return this.client.mget(...this.buildKeys(keys));
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
async mset(values: RedisMSetArgument): Promise<'OK'> {
|
|
86
|
-
|
|
101
|
+
const normalized = normalizeMsetValues(values);
|
|
102
|
+
const prefixed = Object.entries(normalized).reduce<Record<string, RedisValue>>(
|
|
103
|
+
(acc, [key, value]) => {
|
|
104
|
+
acc[this.addPrefixToKey(key)] = value;
|
|
105
|
+
return acc;
|
|
106
|
+
},
|
|
107
|
+
{},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return this.client.mset(prefixed);
|
|
87
111
|
}
|
|
88
112
|
|
|
89
113
|
async hget(key: RedisKey, field: RedisKey): Promise<string | null> {
|
|
90
|
-
return this.client.hget(
|
|
114
|
+
return this.client.hget(this.buildKey(key), normalizeRedisKey(field));
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
async hset(key: RedisKey, field: RedisKey, value: RedisValue): Promise<number> {
|
|
94
|
-
return this.client.hset(
|
|
118
|
+
return this.client.hset(this.buildKey(key), { [normalizeRedisKey(field)]: value });
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
async hdel(key: RedisKey, ...fields: RedisKey[]): Promise<number> {
|
|
98
|
-
return this.client.hdel(
|
|
122
|
+
return this.client.hdel(this.buildKey(key), ...normalizeRedisKeys(fields));
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
async hgetall(key: RedisKey): Promise<Record<string, string>> {
|
|
102
|
-
const res = await this.client.hgetall(
|
|
126
|
+
const res = await this.client.hgetall(this.buildKey(key));
|
|
103
127
|
if (!res) {
|
|
104
128
|
return {};
|
|
105
129
|
}
|