mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 04:55:51 +00:00
✨ feat: add the market auth auto generate way (#10993)
* feat: add the market auth auto generate way * feat: use market trusted client to have auto auth way * chore: update deps
This commit is contained in:
+1
-1
@@ -201,7 +201,7 @@
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^3.1.1",
|
||||
"@lobehub/icons": "^4.0.0",
|
||||
"@lobehub/market-sdk": "^0.24.0",
|
||||
"@lobehub/market-sdk": "^0.25.0",
|
||||
"@lobehub/tts": "^4.0.0",
|
||||
"@lobehub/ui": "^4.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface GlobalServerConfig {
|
||||
aiProvider: ServerLanguageModel;
|
||||
defaultAgent?: PartialDeep<UserDefaultAgent>;
|
||||
enableKlavis?: boolean;
|
||||
enableMarketTrustedClient?: boolean;
|
||||
enableUploadFileToServer?: boolean;
|
||||
enabledAccessCode?: boolean;
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MarketSDK } from '@lobehub/market-sdk';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
segments?: string[];
|
||||
@@ -54,9 +56,12 @@ const notFound = (reason: string) =>
|
||||
|
||||
const handleAgent = async (req: NextRequest, segments: string[]) => {
|
||||
const accessToken = extractAccessToken(req);
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
if (segments.length === 0) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MarketSDK } from '@lobehub/market-sdk';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
segments?: string[];
|
||||
@@ -162,15 +164,38 @@ const handleProxy = async (req: NextRequest, context: RouteContext) => {
|
||||
|
||||
try {
|
||||
const { token } = (await req.json()) as { token?: string };
|
||||
|
||||
// 如果没有 token,尝试使用 trustedClientToken
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_token',
|
||||
message: 'Token is required for userinfo proxy.',
|
||||
status: 'error',
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
if (!trustedClientToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_token',
|
||||
message: 'Token is required for userinfo proxy.',
|
||||
status: 'error',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 trustedClientToken 直接调用 Market userinfo 端点
|
||||
const userInfoUrl = `${MARKET_BASE_URL}/lobehub-oidc/userinfo`;
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-lobe-trust-token': trustedClientToken,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const userInfo = await response.json();
|
||||
return NextResponse.json(userInfo);
|
||||
}
|
||||
|
||||
const response = await market.auth.getUserInfo(token);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MarketSDK } from '@lobehub/market-sdk';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
segments?: string[];
|
||||
@@ -33,19 +35,22 @@ export const POST = async (req: NextRequest, context: RouteContext) => {
|
||||
const { segments = [] } = await context.params;
|
||||
const action = segments[0];
|
||||
const accessToken = getAccessToken(req);
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
if (!accessToken) {
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
// Only require accessToken if trusted client token is not available
|
||||
if (!accessToken && !trustedClientToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'unauthorized', message: 'Access token required' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
@@ -131,10 +136,12 @@ export const GET = async (req: NextRequest, context: RouteContext) => {
|
||||
const { segments = [] } = await context.params;
|
||||
const action = segments[0];
|
||||
const accessToken = getAccessToken(req);
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MarketSDK } from '@lobehub/market-sdk';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
username: string;
|
||||
@@ -18,9 +20,11 @@ const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://mark
|
||||
export const GET = async (req: NextRequest, context: RouteContext) => {
|
||||
const { username } = await context.params;
|
||||
const decodedUsername = decodeURIComponent(username);
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
const market = new MarketSDK({
|
||||
baseURL: MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MarketSDK } from '@lobehub/market-sdk';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getTrustedClientTokenForSession } from '@/libs/trusted-client';
|
||||
|
||||
const MARKET_BASE_URL = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
|
||||
|
||||
const extractAccessToken = (req: NextRequest) => {
|
||||
@@ -17,7 +19,7 @@ const extractAccessToken = (req: NextRequest) => {
|
||||
* PUT /market/user/me
|
||||
*
|
||||
* Updates the authenticated user's profile information.
|
||||
* Requires authentication via Bearer token.
|
||||
* Requires authentication via Bearer token or trusted client token.
|
||||
*
|
||||
* Request body:
|
||||
* - userName?: string - User's unique username
|
||||
@@ -27,8 +29,16 @@ const extractAccessToken = (req: NextRequest) => {
|
||||
*/
|
||||
export const PUT = async (req: NextRequest) => {
|
||||
const accessToken = extractAccessToken(req);
|
||||
const trustedClientToken = await getTrustedClientTokenForSession();
|
||||
|
||||
if (!accessToken) {
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
// Only require accessToken if trusted client token is not available
|
||||
if (!accessToken && !trustedClientToken) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'unauthorized',
|
||||
@@ -39,11 +49,6 @@ export const PUT = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: MARKET_BASE_URL,
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await req.json();
|
||||
|
||||
@@ -59,7 +64,13 @@ export const PUT = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const response = await market.user.updateUserInfo(payload);
|
||||
// Ensure meta is at least an empty object
|
||||
const normalizedPayload = {
|
||||
...payload,
|
||||
meta: payload.meta ?? {},
|
||||
};
|
||||
|
||||
const response = await market.user.updateUserInfo(normalizedPayload);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,25 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useMarketAuth, useMarketUserProfile } from '@/layout/AuthProvider/MarketAuth';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
|
||||
|
||||
/**
|
||||
* 检查用户是否需要完善资料
|
||||
* 当使用 trustedClient 自动授权时,用户的 meta 相关字段会为空
|
||||
*/
|
||||
const checkNeedsProfileSetup = (
|
||||
enableMarketTrustedClient: boolean,
|
||||
userProfile: { avatarUrl: string | null; bannerUrl: string | null; socialLinks: { github?: string; twitter?: string; website?: string } | null } | null | undefined,
|
||||
): boolean => {
|
||||
if (!enableMarketTrustedClient) return false;
|
||||
if (!userProfile) return true;
|
||||
|
||||
// 如果 avatarUrl 字段为空,则需要完善资料
|
||||
const hasAvatarUrl = !!userProfile.avatarUrl;
|
||||
|
||||
return !hasAvatarUrl;
|
||||
};
|
||||
|
||||
const UserAvatar = memo(() => {
|
||||
const { t } = useTranslation('discover');
|
||||
@@ -14,15 +33,25 @@ const UserAvatar = memo(() => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { isAuthenticated, isLoading, getCurrentUserInfo, signIn } = useMarketAuth();
|
||||
|
||||
const enableMarketTrustedClient = useServerConfigStore(serverConfigSelectors.enableMarketTrustedClient);
|
||||
|
||||
const userInfo = getCurrentUserInfo();
|
||||
const username = userInfo?.sub;
|
||||
|
||||
// Use SWR to fetch user profile with caching
|
||||
const { data: userProfile } = useMarketUserProfile(username);
|
||||
|
||||
// 检查是否需要完善资料
|
||||
const needsProfileSetup = checkNeedsProfileSetup(enableMarketTrustedClient, userProfile);
|
||||
|
||||
console.log('needsProfileSetup', needsProfileSetup);
|
||||
|
||||
const handleSignIn = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 统一调用 signIn,会先弹出确认弹窗
|
||||
// trustedClient 模式下确认后会弹出 ProfileSetupModal
|
||||
// OIDC 模式下确认后会走 OIDC 流程
|
||||
await signIn();
|
||||
} catch {
|
||||
// User cancelled or error occurred
|
||||
@@ -30,19 +59,19 @@ const UserAvatar = memo(() => {
|
||||
setLoading(false);
|
||||
}, [signIn]);
|
||||
|
||||
const handleNavigateToProfile = useCallback(() => {
|
||||
// Use userName from profile for the URL (not OIDC sub/id)
|
||||
const handleAvatarClick = useCallback(() => {
|
||||
const profileUserName = userProfile?.userName || userProfile?.namespace;
|
||||
if (profileUserName) {
|
||||
navigate(`/community/user/${profileUserName}`);
|
||||
}
|
||||
}, [navigate, userProfile?.userName]);
|
||||
}, [navigate, userProfile?.userName, userProfile?.namespace]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton.Avatar active shape={'square'} size={28} style={{ borderRadius: 6 }} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// 未认证,或者是 trustedClient 模式但需要完善资料时,显示登录按钮
|
||||
if (!isAuthenticated || needsProfileSetup) {
|
||||
return (
|
||||
<Button
|
||||
icon={UserCircleIcon}
|
||||
@@ -64,7 +93,7 @@ const UserAvatar = memo(() => {
|
||||
return (
|
||||
<Avatar
|
||||
avatar={avatarUrl || userProfile?.userName}
|
||||
onClick={handleNavigateToProfile}
|
||||
onClick={handleAvatarClick}
|
||||
shape={'square'}
|
||||
size={28}
|
||||
/>
|
||||
|
||||
@@ -61,6 +61,20 @@ export const getAppConfig = () => {
|
||||
SSRF_ALLOW_IP_ADDRESS_LIST: z.string().optional(),
|
||||
MARKET_BASE_URL: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Trusted Client Secret for Market API authentication
|
||||
* 64-character hex string (32 bytes) shared with Market server
|
||||
* Used to encrypt user payload for trusted client authentication
|
||||
* Generate with: openssl rand -hex 32
|
||||
*/
|
||||
MARKET_TRUSTED_CLIENT_SECRET: z.string().length(83).optional(),
|
||||
/**
|
||||
* Trusted Client ID for Market API authentication
|
||||
* Must be registered in Market's TRUSTED_CLIENT_IDS whitelist
|
||||
* e.g., "lobechat-com", "lobehub-desktop"
|
||||
*/
|
||||
MARKET_TRUSTED_CLIENT_ID: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Enable Queue-based Agent Runtime
|
||||
* When true, use QStash for async agent execution (production)
|
||||
@@ -103,6 +117,9 @@ export const getAppConfig = () => {
|
||||
SSRF_ALLOW_IP_ADDRESS_LIST: process.env.SSRF_ALLOW_IP_ADDRESS_LIST,
|
||||
MARKET_BASE_URL: process.env.MARKET_BASE_URL,
|
||||
|
||||
MARKET_TRUSTED_CLIENT_SECRET: process.env.MARKET_TRUSTED_CLIENT_SECRET,
|
||||
MARKET_TRUSTED_CLIENT_ID: process.env.MARKET_TRUSTED_CLIENT_ID,
|
||||
|
||||
enableQueueAgentRuntime: process.env.AGENT_RUNTIME_MODE === 'queue',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { mutate as globalMutate } from 'swr';
|
||||
|
||||
import { MARKET_ENDPOINTS, MARKET_OIDC_ENDPOINTS } from '@/services/_url';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/slices/settings/selectors/settings';
|
||||
|
||||
@@ -31,8 +33,9 @@ interface MarketAuthProviderProps {
|
||||
|
||||
/**
|
||||
* 获取用户信息(从 OIDC userinfo endpoint)
|
||||
* @param accessToken - 可选的 access token,如果不传则后端会尝试使用 trustedClientToken
|
||||
*/
|
||||
const fetchUserInfo = async (accessToken: string): Promise<MarketUserInfo | null> => {
|
||||
const fetchUserInfo = async (accessToken?: string): Promise<MarketUserInfo | null> => {
|
||||
try {
|
||||
const response = await fetch(MARKET_OIDC_ENDPOINTS.userinfo, {
|
||||
body: JSON.stringify({ token: accessToken }),
|
||||
@@ -173,6 +176,9 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
|
||||
// 订阅 user store 的初始化状态,当 isUserStateInit 为 true 时,settings 数据已加载完成
|
||||
const isUserStateInit = useUserStore((s) => s.isUserStateInit);
|
||||
|
||||
// 检查是否启用了 Market Trusted Client 认证
|
||||
const enableMarketTrustedClient = useServerConfigStore(serverConfigSelectors.enableMarketTrustedClient);
|
||||
|
||||
// 初始化 OIDC 客户端(仅在客户端)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -200,6 +206,32 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
|
||||
const initializeSession = async () => {
|
||||
setStatus('loading');
|
||||
|
||||
// 如果启用了 Trusted Client 认证,直接通过后端获取用户信息(不传 token)
|
||||
if (enableMarketTrustedClient) {
|
||||
const userInfo = await fetchUserInfo();
|
||||
|
||||
if (userInfo) {
|
||||
// 使用 Trusted Client 时,创建一个虚拟的 session(无需真实 token)
|
||||
const trustedSession: MarketAuthSession = {
|
||||
accessToken: '', // Trusted Client 不需要前端 token
|
||||
expiresAt: Number.MAX_SAFE_INTEGER, // 不过期
|
||||
expiresIn: Number.MAX_SAFE_INTEGER,
|
||||
scope: 'openid profile email',
|
||||
tokenType: 'Bearer',
|
||||
userInfo,
|
||||
};
|
||||
|
||||
setSession(trustedSession);
|
||||
setStatus('authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果获取失败,设置为未认证状态
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// 原有的 OIDC token 认证流程
|
||||
const dbTokens = getMarketTokensFromDB();
|
||||
|
||||
// 检查 DB 中是否有 token
|
||||
@@ -324,6 +356,20 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
|
||||
*/
|
||||
const handleConfirmAuth = async () => {
|
||||
setShowConfirmModal(false);
|
||||
|
||||
// 如果是 trustedClient 模式,直接打开 ProfileSetupModal 完善资料
|
||||
if (enableMarketTrustedClient) {
|
||||
setIsFirstTimeSetup(true);
|
||||
setShowProfileSetupModal(true);
|
||||
if (pendingSignInResolve) {
|
||||
pendingSignInResolve(session?.userInfo?.accountId ?? null);
|
||||
setPendingSignInResolve(null);
|
||||
setPendingSignInReject(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 原有的 OIDC 流程
|
||||
try {
|
||||
const result = await handleActualSignIn();
|
||||
if (pendingSignInResolve) {
|
||||
@@ -415,13 +461,14 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
|
||||
if (isUserStateInit) {
|
||||
initializeSession();
|
||||
}
|
||||
}, [isUserStateInit]);
|
||||
}, [isUserStateInit, enableMarketTrustedClient]);
|
||||
|
||||
const contextValue: MarketAuthContextType = {
|
||||
getAccessToken,
|
||||
getCurrentUserInfo,
|
||||
getRefreshToken,
|
||||
isAuthenticated: status === 'authenticated',
|
||||
// 当启用 Trusted Client 认证时,自动视为已认证(后端会自动使用 trustedClientToken)
|
||||
isAuthenticated: enableMarketTrustedClient || status === 'authenticated',
|
||||
isLoading: status === 'loading',
|
||||
openProfileSetup,
|
||||
refreshToken,
|
||||
|
||||
@@ -13,6 +13,10 @@ import { MARKET_ENDPOINTS } from '@/services/_url';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { globalGeneralSelectors } from '@/store/global/selectors';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { type MarketUserProfile } from './types';
|
||||
|
||||
@@ -66,6 +70,12 @@ const ProfileSetupModal = memo<ProfileSetupModalProps>(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
|
||||
|
||||
// 检查是否是自动授权模式
|
||||
const enableMarketTrustedClient = useServerConfigStore(serverConfigSelectors.enableMarketTrustedClient);
|
||||
|
||||
// 获取当前用户头像作为默认值
|
||||
const currentUserAvatar = useUserStore(userProfileSelectors.userAvatar);
|
||||
|
||||
// Avatar state
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
@@ -99,10 +109,11 @@ const ProfileSetupModal = memo<ProfileSetupModalProps>(
|
||||
});
|
||||
|
||||
// Reset avatar and banner
|
||||
setAvatarUrl(userProfile?.avatarUrl || null);
|
||||
// 如果 userProfile 有 avatarUrl 就用它,否则用当前用户头像作为默认值
|
||||
setAvatarUrl(userProfile?.avatarUrl || currentUserAvatar || null);
|
||||
setBannerUrl(userProfile?.bannerUrl || null);
|
||||
}
|
||||
}, [open, userProfile, defaultDisplayName, form]);
|
||||
}, [open, userProfile, defaultDisplayName, form, currentUserAvatar]);
|
||||
|
||||
// Handle avatar change (emoji)
|
||||
const handleAvatarChange = useCallback((emoji: string) => {
|
||||
@@ -173,7 +184,8 @@ const ProfileSetupModal = memo<ProfileSetupModalProps>(
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
// 如果不是自动授权模式,需要校验 accessToken
|
||||
if (!enableMarketTrustedClient && !accessToken) {
|
||||
message.error(t('profileSetup.errors.notAuthenticated'));
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +245,7 @@ const ProfileSetupModal = memo<ProfileSetupModalProps>(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, avatarUrl, bannerUrl, form, message, onClose, onSuccess, t]);
|
||||
}, [accessToken, avatarUrl, bannerUrl, enableMarketTrustedClient, form, message, onClose, onSuccess, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!isFirstTimeSetup) {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './keyVaults';
|
||||
export * from './marketUserInfo';
|
||||
export * from './serverDatabase';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import type { TrustedClientUserInfo } from '@/libs/trusted-client';
|
||||
|
||||
import { trpc } from '../init';
|
||||
|
||||
interface ContextWithServerDB {
|
||||
serverDB?: LobeChatDatabase;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that fetches user info for Market trusted client authentication
|
||||
* This requires serverDatabase middleware to be applied first
|
||||
*/
|
||||
export const marketUserInfo = trpc.middleware(async (opts) => {
|
||||
const ctx = opts.ctx as ContextWithServerDB;
|
||||
|
||||
// If userId or serverDB is not available, skip fetching user info
|
||||
if (!ctx.userId || !ctx.serverDB) {
|
||||
return opts.next({
|
||||
ctx: { marketUserInfo: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await UserModel.findById(ctx.serverDB, ctx.userId);
|
||||
|
||||
if (!user || !user.email) {
|
||||
return opts.next({
|
||||
ctx: { marketUserInfo: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
const marketUserInfo: TrustedClientUserInfo = {
|
||||
email: user.email,
|
||||
name: user.fullName || user.username || undefined,
|
||||
userId: ctx.userId,
|
||||
};
|
||||
|
||||
return opts.next({
|
||||
ctx: { marketUserInfo },
|
||||
});
|
||||
} catch {
|
||||
// If fetching user info fails, continue without it
|
||||
return opts.next({
|
||||
ctx: { marketUserInfo: undefined },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
|
||||
|
||||
import type { TrustedClientUserInfo } from './index';
|
||||
|
||||
/**
|
||||
* Get user info from the current session for trusted client authentication
|
||||
* This works with different authentication providers (Clerk, BetterAuth, NextAuth)
|
||||
*
|
||||
* @returns User info or undefined if not authenticated
|
||||
*/
|
||||
export const getSessionUser = async (): Promise<TrustedClientUserInfo | undefined> => {
|
||||
try {
|
||||
if (enableClerk) {
|
||||
const { currentUser } = await import('@clerk/nextjs/server');
|
||||
const user = await currentUser();
|
||||
|
||||
if (!user?.id || !user?.primaryEmailAddress?.emailAddress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
email: user.primaryEmailAddress.emailAddress,
|
||||
name: user.fullName || user.firstName || undefined,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (!session?.user?.id || !session?.user?.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
email: session.user.email,
|
||||
name: session.user.name || undefined,
|
||||
userId: session.user.id,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { buildTrustedClientPayload, createTrustedClientToken } from '@lobehub/market-sdk';
|
||||
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
export interface TrustedClientUserInfo {
|
||||
email: string;
|
||||
name?: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export { getSessionUser } from './getSessionUser';
|
||||
|
||||
/**
|
||||
* Check if trusted client authentication is enabled
|
||||
*/
|
||||
export const isTrustedClientEnabled = (): boolean => {
|
||||
return !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate trusted client token for a specific user
|
||||
* This token includes encrypted user info and is used for Market API authentication
|
||||
*
|
||||
* @param userInfo - User information (userId, email, name)
|
||||
* @returns Encrypted token string or undefined if not configured
|
||||
*/
|
||||
export const generateTrustedClientToken = (userInfo: TrustedClientUserInfo): string | undefined => {
|
||||
const { MARKET_TRUSTED_CLIENT_SECRET, MARKET_TRUSTED_CLIENT_ID } = appEnv;
|
||||
|
||||
if (!MARKET_TRUSTED_CLIENT_SECRET || !MARKET_TRUSTED_CLIENT_ID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!userInfo.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = buildTrustedClientPayload({
|
||||
clientId: MARKET_TRUSTED_CLIENT_ID,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
userId: userInfo.userId,
|
||||
});
|
||||
|
||||
return createTrustedClientToken(payload, MARKET_TRUSTED_CLIENT_SECRET);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate trusted client token:', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get trusted client token for the current session user
|
||||
* This is a convenience function that combines getSessionUser and generateTrustedClientToken
|
||||
*
|
||||
* @returns Encrypted token string or undefined if not configured or user not authenticated
|
||||
*/
|
||||
export const getTrustedClientTokenForSession = async (): Promise<string | undefined> => {
|
||||
const { getSessionUser } = await import('./getSessionUser');
|
||||
const userInfo = await getSessionUser();
|
||||
|
||||
if (!userInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return generateTrustedClientToken(userInfo);
|
||||
};
|
||||
@@ -66,6 +66,7 @@ export const getServerGlobalConfig = async () => {
|
||||
config: parseAgentConfig(DEFAULT_AGENT_CONFIG),
|
||||
},
|
||||
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
|
||||
enableMarketTrustedClient: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
|
||||
enableUploadFileToServer: !!fileEnv.S3_SECRET_ACCESS_KEY,
|
||||
enabledAccessCode: ACCESS_CODES?.length > 0,
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
||||
|
||||
import { type ToolCallContent } from '@/libs/mcp';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { DiscoverService } from '@/server/services/discover';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import {
|
||||
@@ -26,27 +26,40 @@ const log = debug('lambda-router:market');
|
||||
|
||||
const marketSourceSchema = z.enum(['legacy', 'new']);
|
||||
|
||||
const marketProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
discoverService: new DiscoverService({ accessToken: ctx.marketAccessToken }),
|
||||
},
|
||||
// Public procedure with optional user info for trusted client token
|
||||
const marketProcedure = publicProcedure
|
||||
.use(serverDatabase)
|
||||
.use(marketUserInfo)
|
||||
.use(async ({ ctx, next }) => {
|
||||
return next({
|
||||
ctx: {
|
||||
discoverService: new DiscoverService({
|
||||
accessToken: ctx.marketAccessToken,
|
||||
userInfo: ctx.marketUserInfo,
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Procedure with user authentication for operations requiring user access token
|
||||
const authedMarketProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
|
||||
const { UserModel } = await import('@/database/models/user');
|
||||
const userModel = new UserModel(ctx.serverDB, ctx.userId);
|
||||
const authedMarketProcedure = authedProcedure
|
||||
.use(serverDatabase)
|
||||
.use(marketUserInfo)
|
||||
.use(async ({ ctx, next }) => {
|
||||
const { UserModel } = await import('@/database/models/user');
|
||||
const userModel = new UserModel(ctx.serverDB, ctx.userId);
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
discoverService: new DiscoverService({ accessToken: ctx.marketAccessToken }),
|
||||
fileService: new FileService(ctx.serverDB, ctx.userId),
|
||||
userModel,
|
||||
},
|
||||
return next({
|
||||
ctx: {
|
||||
discoverService: new DiscoverService({
|
||||
accessToken: ctx.marketAccessToken,
|
||||
userInfo: ctx.marketUserInfo,
|
||||
}),
|
||||
fileService: new FileService(ctx.serverDB, ctx.userId),
|
||||
userModel,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const marketRouter = router({
|
||||
// ============================== Cloud MCP Gateway ==============================
|
||||
|
||||
@@ -5,12 +5,13 @@ import { z } from 'zod';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { marketUserInfo, serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { generateTrustedClientToken } from '@/libs/trusted-client';
|
||||
import { FileS3 } from '@/server/modules/S3';
|
||||
|
||||
const log = debug('lobe-server:tools:code-interpreter');
|
||||
|
||||
const codeInterpreterProcedure = authedProcedure;
|
||||
const codeInterpreterProcedure = authedProcedure.use(serverDatabase).use(marketUserInfo);
|
||||
|
||||
// Schema for tool call request
|
||||
const callToolSchema = z.object({
|
||||
@@ -82,7 +83,7 @@ export interface SaveExportedFileContentResult {
|
||||
}
|
||||
|
||||
export const codeInterpreterRouter = router({
|
||||
callTool: codeInterpreterProcedure.input(callToolSchema).mutation(async ({ input }) => {
|
||||
callTool: codeInterpreterProcedure.input(callToolSchema).mutation(async ({ input, ctx }) => {
|
||||
const { toolName, params, userId, topicId, marketAccessToken } = input;
|
||||
|
||||
log('Calling cloud code interpreter tool: %s with params: %O', toolName, {
|
||||
@@ -92,11 +93,17 @@ export const codeInterpreterRouter = router({
|
||||
});
|
||||
log('Market access token available: %s', marketAccessToken ? 'yes' : 'no');
|
||||
|
||||
// Generate trusted client token if user info is available
|
||||
const trustedClientToken = ctx.marketUserInfo
|
||||
? generateTrustedClientToken(ctx.marketUserInfo)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
// Initialize MarketSDK with market access token from OIDC (passed via input)
|
||||
// Initialize MarketSDK with market access token and trusted client token
|
||||
const market = new MarketSDK({
|
||||
accessToken: marketAccessToken,
|
||||
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
|
||||
// Call market-sdk's runBuildInTool
|
||||
|
||||
@@ -42,7 +42,12 @@ import {
|
||||
getTextInputUnitRate,
|
||||
getTextOutputUnitRate,
|
||||
} from '@lobechat/utils';
|
||||
import { type CategoryItem, type CategoryListQuery, MarketSDK, type UserInfoResponse } from '@lobehub/market-sdk';
|
||||
import {
|
||||
type CategoryItem,
|
||||
type CategoryListQuery,
|
||||
MarketSDK,
|
||||
type UserInfoResponse,
|
||||
} from '@lobehub/market-sdk';
|
||||
import { type CallReportRequest, type InstallReportRequest } from '@lobehub/market-types';
|
||||
import dayjs from 'dayjs';
|
||||
import debug from 'debug';
|
||||
@@ -50,25 +55,41 @@ import { cloneDeep, countBy, isString, merge, uniq, uniqBy } from 'es-toolkit/co
|
||||
import matter from 'gray-matter';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { type TrustedClientUserInfo, generateTrustedClientToken } from '@/libs/trusted-client';
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
import { AssistantStore } from '@/server/modules/AssistantStore';
|
||||
import { PluginStore } from '@/server/modules/PluginStore';
|
||||
|
||||
const log = debug('lobe-server:discover');
|
||||
|
||||
export interface DiscoverServiceOptions {
|
||||
/** Access token from OIDC flow (legacy) */
|
||||
accessToken?: string;
|
||||
/** User info for generating trusted client token */
|
||||
userInfo?: TrustedClientUserInfo;
|
||||
}
|
||||
|
||||
export class DiscoverService {
|
||||
assistantStore = new AssistantStore();
|
||||
pluginStore = new PluginStore();
|
||||
market: MarketSDK;
|
||||
|
||||
constructor({ accessToken }: { accessToken?: string } = {}) {
|
||||
constructor(options: DiscoverServiceOptions = {}) {
|
||||
const { accessToken, userInfo } = options;
|
||||
|
||||
// Generate trusted client token if user info is available
|
||||
const trustedClientToken = userInfo ? generateTrustedClientToken(userInfo) : undefined;
|
||||
|
||||
this.market = new MarketSDK({
|
||||
accessToken,
|
||||
baseURL: process.env.NEXT_PUBLIC_MARKET_BASE_URL,
|
||||
trustedClientToken,
|
||||
});
|
||||
log(
|
||||
'DiscoverService initialized with market baseURL: %s',
|
||||
'DiscoverService initialized with market baseURL: %s, hasTrustedToken: %s, userId: %s',
|
||||
process.env.NEXT_PUBLIC_MARKET_BASE_URL,
|
||||
!!trustedClientToken,
|
||||
userInfo?.userId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export const featureFlagsSelectors = (s: ServerConfigStore) => s.featureFlags;
|
||||
|
||||
export const serverConfigSelectors = {
|
||||
enableKlavis: (s: ServerConfigStore) => s.serverConfig.enableKlavis || false,
|
||||
enableMarketTrustedClient: (s: ServerConfigStore) => s.serverConfig.enableMarketTrustedClient || false,
|
||||
enableUploadFileToServer: (s: ServerConfigStore) => s.serverConfig.enableUploadFileToServer,
|
||||
enabledAccessCode: (s: ServerConfigStore) => !!s.serverConfig?.enabledAccessCode,
|
||||
enabledTelemetryChat: (s: ServerConfigStore) => s.serverConfig.telemetry.langfuse || false,
|
||||
|
||||
Reference in New Issue
Block a user