mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat(cli): support api key auth in cli (#13190)
* ✨ support cli api key auth * 🔒 reject invalid x-api-key without fallback auth * ♻️ clean up cli api key auth diff * ♻️ clean up cli auth command diff * ♻️ clean up remaining cli auth diff * ♻️ split stored auth token fields * ♻️ trim connect auth surface * ♻️ drop redundant jwt user id carry-over * ♻️ trim auth test wording diff * 🐛 fix api key model imports * 🐛 fix api key util subpath import * 🔐 chore(cli): use env-only api key auth * ♻️ refactor(cli): simplify auth credential flow * ✨ feat: simplify cli api key login flow * 🐛 fix(cli): prefer jwt for webapi auth * ♻️ refactor(cli): trim auth http diff * 🐛 fix(cli): skip api key auth expiry handling * 🐛 fix(cli): restore non-jwt expiry handling * ♻️ refactor(cli): trim connect auth expired diff * ♻️ refactor(cli): trim login comment diff * ♻️ refactor(cli): trim resolve token comment diff * ♻️ refactor(cli): restore connect expiry flow * ♻️ refactor(cli): trim login api key message * 🐛 fix(cli): support api key gateway auth * ♻️ refactor(cli): restore resolve token comment * ♻️ refactor(cli): trim test-only auth diffs * ♻️ refactor(cli): restore resolve token comments * ✅ test(cli): add api key expiry coverage * 🐛 fix cli auth server resolution and gateway auth * ♻️ prune auth fix diff noise * ♻️ unify cli server url precedence * ♻️ simplify device gateway auth tests * ✅ add gateway auth edge case coverage * ✅ remove low-value gateway auth test * 🐛 fix api key context test mock typing
This commit is contained in:
parent
b76db6bcbd
commit
4dd271c968
25 changed files with 793 additions and 78 deletions
|
|
@ -5,8 +5,8 @@ import type { LambdaRouter } from '@/server/routers/lambda';
|
|||
import type { ToolsRouter } from '@/server/routers/tools';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
|
||||
|
|
@ -19,31 +19,46 @@ async function getAuthAndServer() {
|
|||
// LOBEHUB_JWT + LOBEHUB_SERVER env vars (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const serverUrl = process.env.LOBEHUB_SERVER || OFFICIAL_SERVER_URL;
|
||||
return { accessToken: envJwt, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error("No authentication found. Run 'lh login' first.");
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const accessToken = result.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return { accessToken, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
if (_client) return _client;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
const { headers, serverUrl } = await getAuthAndServer();
|
||||
_client = createTRPCClient<LambdaRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
headers,
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/lambda`,
|
||||
}),
|
||||
|
|
@ -56,12 +71,11 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
|||
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
|
||||
if (_toolsClient) return _toolsClient;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
const { headers, serverUrl } = await getAuthAndServer();
|
||||
_toolsClient = createTRPCClient<ToolsRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
headers,
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/tools`,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
|
|
@ -33,12 +33,19 @@ export interface AuthInfo {
|
|||
export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
if (process.env[CLI_API_KEY_ENV]) {
|
||||
log.error(
|
||||
`API key auth from ${CLI_API_KEY_ENV} is not supported for /webapi/* routes. Run OIDC login instead.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.error("No authentication found. Run 'lh login' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const accessToken = result!.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
|
@ -47,6 +54,6 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
|||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl: serverUrl.replace(/\/$/, ''),
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
41
apps/cli/src/auth/apiKey.ts
Normal file
41
apps/cli/src/auth/apiKey.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { normalizeUrl, resolveServerUrl } from '../settings';
|
||||
|
||||
interface CurrentUserResponse {
|
||||
data?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): Promise<string> {
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl) || resolveServerUrl();
|
||||
|
||||
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
let body: CurrentUserResponse | undefined;
|
||||
try {
|
||||
body = (await response.json()) as CurrentUserResponse;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
|
||||
}
|
||||
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new Error(
|
||||
body?.error || body?.message || `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = body?.data?.id || body?.data?.userId;
|
||||
if (!userId) {
|
||||
throw new Error('Current user response did not include a user id.');
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
|
|
@ -20,7 +19,7 @@ export async function getValidToken(): Promise<{ credentials: StoredCredentials
|
|||
// Token expired — try refresh
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
const refreshed = await refreshAccessToken(serverUrl, credentials.refreshToken);
|
||||
if (!refreshed) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getUserIdFromApiKey } from './apiKey';
|
||||
import { getValidToken } from './refresh';
|
||||
import { resolveToken } from './resolveToken';
|
||||
|
||||
vi.mock('./apiKey', () => ({
|
||||
getUserIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock('./refresh', () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue({ serverUrl: 'https://app.lobehub.com' }),
|
||||
resolveServerUrl: vi.fn(() =>
|
||||
(process.env.LOBEHUB_SERVER || 'https://app.lobehub.com').replace(/\/$/, ''),
|
||||
),
|
||||
}));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
|
|
@ -25,14 +34,23 @@ function makeJwt(sub: string): string {
|
|||
|
||||
describe('resolveToken', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalApiKey = process.env.LOBEHUB_CLI_API_KEY;
|
||||
const originalJwt = process.env.LOBEHUB_JWT;
|
||||
const originalServer = process.env.LOBEHUB_SERVER;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
delete process.env.LOBEHUB_CLI_API_KEY;
|
||||
delete process.env.LOBEHUB_JWT;
|
||||
delete process.env.LOBEHUB_SERVER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = originalApiKey;
|
||||
process.env.LOBEHUB_JWT = originalJwt;
|
||||
process.env.LOBEHUB_SERVER = originalServer;
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -42,7 +60,12 @@ describe('resolveToken', () => {
|
|||
|
||||
const result = await resolveToken({ token });
|
||||
|
||||
expect(result).toEqual({ token, userId: 'user-123' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token,
|
||||
tokenType: 'jwt',
|
||||
userId: 'user-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if JWT has no sub claim', async () => {
|
||||
|
|
@ -67,7 +90,12 @@ describe('resolveToken', () => {
|
|||
userId: 'user-456',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ token: 'svc-token', userId: 'user-456' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'svc-token',
|
||||
tokenType: 'serviceToken',
|
||||
userId: 'user-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if --user-id is not provided', async () => {
|
||||
|
|
@ -76,6 +104,37 @@ describe('resolveToken', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('with environment api key', () => {
|
||||
it('should return API key from environment', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-789');
|
||||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith('sk-lh-test', 'https://app.lobehub.com');
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'sk-lh-test',
|
||||
tokenType: 'apiKey',
|
||||
userId: 'user-789',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer LOBEHUB_SERVER when validating the API key', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-test';
|
||||
process.env.LOBEHUB_SERVER = 'https://self-hosted.example.com/';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-789');
|
||||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith(
|
||||
'sk-lh-test',
|
||||
'https://self-hosted.example.com',
|
||||
);
|
||||
expect(result.serverUrl).toBe('https://self-hosted.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with stored credentials', () => {
|
||||
it('should return stored credentials token', async () => {
|
||||
const token = makeJwt('stored-user');
|
||||
|
|
@ -87,7 +146,12 @@ describe('resolveToken', () => {
|
|||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(result).toEqual({ token, userId: 'stored-user' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token,
|
||||
tokenType: 'jwt',
|
||||
userId: 'stored-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if stored token has no sub', async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
import { getUserIdFromApiKey } from './apiKey';
|
||||
import { getValidToken } from './refresh';
|
||||
|
||||
interface ResolveTokenOptions {
|
||||
|
|
@ -8,7 +11,9 @@ interface ResolveTokenOptions {
|
|||
}
|
||||
|
||||
interface ResolvedAuth {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
userId: string;
|
||||
}
|
||||
|
||||
|
|
@ -25,20 +30,21 @@ function parseJwtSub(token: string): string | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve an access token from explicit options or stored credentials.
|
||||
* Resolve an access token from explicit options, environment variables, or stored credentials.
|
||||
* Exits the process if no token can be resolved.
|
||||
*/
|
||||
export async function resolveToken(options: ResolveTokenOptions): Promise<ResolvedAuth> {
|
||||
// LOBEHUB_JWT env var takes highest priority (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const serverUrl = resolveServerUrl();
|
||||
const userId = parseJwtSub(envJwt);
|
||||
if (!userId) {
|
||||
log.error('Could not extract userId from LOBEHUB_JWT.');
|
||||
process.exit(1);
|
||||
}
|
||||
log.debug('Using LOBEHUB_JWT from environment');
|
||||
return { token: envJwt, userId };
|
||||
return { serverUrl, token: envJwt, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
// Explicit token takes priority
|
||||
|
|
@ -48,7 +54,7 @@ export async function resolveToken(options: ResolveTokenOptions): Promise<Resolv
|
|||
log.error('Could not extract userId from token. Provide --user-id explicitly.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { token: options.token, userId };
|
||||
return { serverUrl: resolveServerUrl(), token: options.token, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
if (options.serviceToken) {
|
||||
|
|
@ -56,22 +62,46 @@ export async function resolveToken(options: ResolveTokenOptions): Promise<Resolv
|
|||
log.error('--user-id is required when using --service-token');
|
||||
process.exit(1);
|
||||
}
|
||||
return { token: options.serviceToken, userId: options.userId };
|
||||
return {
|
||||
serverUrl: resolveServerUrl(),
|
||||
token: options.serviceToken,
|
||||
tokenType: 'serviceToken',
|
||||
userId: options.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
try {
|
||||
const serverUrl = resolveServerUrl();
|
||||
const userId = await getUserIdFromApiKey(envApiKey, serverUrl);
|
||||
log.debug(`Using ${CLI_API_KEY_ENV} from environment`);
|
||||
return { serverUrl, token: envApiKey, tokenType: 'apiKey', userId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to validate ${CLI_API_KEY_ENV}: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Try stored credentials
|
||||
const result = await getValidToken();
|
||||
if (result) {
|
||||
log.debug('Using stored credentials');
|
||||
const token = result.credentials.accessToken;
|
||||
const userId = parseJwtSub(token);
|
||||
const { credentials } = result;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const userId = parseJwtSub(credentials.accessToken);
|
||||
if (!userId) {
|
||||
log.error("Stored token is invalid. Run 'lh login' again.");
|
||||
process.exit(1);
|
||||
}
|
||||
return { token, userId };
|
||||
|
||||
return { serverUrl, token: credentials.accessToken, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
log.error("No authentication found. Run 'lh login' first, or provide --token.");
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}, or provide --token.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import { Command } from 'commander';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -161,6 +167,12 @@ describe('connect command', () => {
|
|||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
it('should pass the resolved serverUrl to GatewayClient', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(clientOptions.serverUrl).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should handle tool call requests', async () => {
|
||||
const program = createProgram();
|
||||
|
|
@ -208,7 +220,12 @@ describe('connect command', () => {
|
|||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({ token: 'new-tok', userId: 'user' });
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'new-tok',
|
||||
tokenType: 'jwt',
|
||||
userId: 'user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
|
@ -220,6 +237,24 @@ describe('connect command', () => {
|
|||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should ignore auth_expired for api key auth', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://self-hosted.example.com',
|
||||
token: 'test-api-key',
|
||||
tokenType: 'apiKey',
|
||||
userId: 'user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
await clientEventHandlers['auth_expired']?.();
|
||||
|
||||
expect(log.error).not.toHaveBeenCalled();
|
||||
expect(cleanupAllProcesses).not.toHaveBeenCalled();
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle error event', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { GatewayClient } from '@lobechat/device-gateway-client';
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import {
|
||||
appendLog,
|
||||
|
|
@ -23,7 +24,7 @@ import {
|
|||
stopDaemon,
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
|
@ -174,7 +175,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
|||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
|
|
@ -194,7 +195,9 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
deviceId: options.deviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
serverUrl: auth.serverUrl,
|
||||
token: auth.token,
|
||||
tokenType: auth.tokenType,
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
|
|
@ -214,7 +217,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
info(` Hostname : ${os.hostname()}`);
|
||||
info(` Platform : ${process.platform}`);
|
||||
info(` Gateway : ${resolvedGatewayUrl}`);
|
||||
info(` Auth : jwt`);
|
||||
info(` Auth : ${auth.tokenType}`);
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
|
|
@ -285,13 +288,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error("Run 'lh login' to re-authenticate.");
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle auth expired
|
||||
client.on('auth_expired', async () => {
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
return;
|
||||
}
|
||||
|
||||
error('Authentication expired. Attempting to refresh...');
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ import fs from 'node:fs';
|
|||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getUserIdFromApiKey } from '../auth/apiKey';
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerLoginCommand, resolveCommandExecutable } from './login';
|
||||
|
||||
vi.mock('../auth/apiKey', () => ({
|
||||
getUserIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock('../auth/credentials', () => ({
|
||||
saveCredentials: vi.fn(),
|
||||
}));
|
||||
|
|
@ -37,6 +41,7 @@ vi.mock('node:child_process', () => ({
|
|||
|
||||
describe('login command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalApiKey = process.env.LOBEHUB_CLI_API_KEY;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathext = process.env.PATHEXT;
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
|
|
@ -46,11 +51,13 @@ describe('login command', () => {
|
|||
vi.stubGlobal('fetch', vi.fn());
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
vi.mocked(loadSettings).mockReturnValue(null);
|
||||
delete process.env.LOBEHUB_CLI_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
exitSpy.mockRestore();
|
||||
process.env.LOBEHUB_CLI_API_KEY = originalApiKey;
|
||||
process.env.PATH = originalPath;
|
||||
process.env.PATHEXT = originalPathext;
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
|
|
@ -102,8 +109,12 @@ describe('login command', () => {
|
|||
} as any;
|
||||
}
|
||||
|
||||
async function runLogin(program: Command, args: string[] = []) {
|
||||
return program.parseAsync(['node', 'test', 'login', ...args]);
|
||||
}
|
||||
|
||||
async function runLoginAndAdvanceTimers(program: Command, args: string[] = []) {
|
||||
const parsePromise = program.parseAsync(['node', 'test', 'login', ...args]);
|
||||
const parsePromise = runLogin(program, args);
|
||||
// Advance timers to let sleep resolve in the polling loop
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
|
@ -130,6 +141,19 @@ describe('login command', () => {
|
|||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
|
||||
});
|
||||
|
||||
it('should use environment api key without storing credentials', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-env-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-123');
|
||||
|
||||
const program = createProgram();
|
||||
await runLogin(program);
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith('sk-lh-env-test', 'https://app.lobehub.com');
|
||||
expect(saveCredentials).not.toHaveBeenCalled();
|
||||
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://app.lobehub.com' });
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
|
||||
});
|
||||
|
||||
it('should persist custom server into settings', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
|
|
@ -159,6 +183,23 @@ describe('login command', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should preserve existing gateway for environment api key on the same server', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-env-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-123');
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await runLogin(program, ['--server', 'https://test.com/']);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear existing gateway when logging into a different server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import path from 'node:path';
|
|||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getUserIdFromApiKey } from '../auth/apiKey';
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
|
|
@ -51,13 +53,43 @@ async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T>
|
|||
export function registerLoginCommand(program: Command) {
|
||||
program
|
||||
.command('login')
|
||||
.description('Log in to LobeHub via browser (Device Code Flow)')
|
||||
.description('Log in to LobeHub via browser (Device Code Flow) or configure API key server')
|
||||
.option('--server <url>', 'LobeHub server URL', OFFICIAL_SERVER_URL)
|
||||
.action(async (options: LoginOptions) => {
|
||||
const serverUrl = options.server.replace(/\/$/, '');
|
||||
const serverUrl = normalizeUrl(options.server) || OFFICIAL_SERVER_URL;
|
||||
|
||||
log.info('Starting login...');
|
||||
|
||||
const apiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (apiKey) {
|
||||
try {
|
||||
await getUserIdFromApiKey(apiKey, serverUrl);
|
||||
|
||||
const existingSettings = loadSettings();
|
||||
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
|
||||
|
||||
saveSettings(
|
||||
shouldPreserveGateway
|
||||
? {
|
||||
gatewayUrl: existingSettings.gatewayUrl,
|
||||
serverUrl,
|
||||
}
|
||||
: {
|
||||
// Gateway auth is tied to the login server's token issuer/JWKS.
|
||||
// When server changes, clear old gateway to avoid stale cross-environment config.
|
||||
serverUrl,
|
||||
},
|
||||
);
|
||||
log.info('Login successful! Credentials saved.');
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`API key validation failed: ${message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device code
|
||||
let deviceAuth: DeviceAuthResponse;
|
||||
try {
|
||||
|
|
@ -164,6 +196,7 @@ export function registerLoginCommand(program: Command) {
|
|||
: undefined,
|
||||
refreshToken: body.refresh_token,
|
||||
});
|
||||
|
||||
const existingSettings = loadSettings();
|
||||
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
// Mock resolveToken
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -115,6 +121,16 @@ describe('status command', () => {
|
|||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
it('should pass the resolved serverUrl to GatewayClient', async () => {
|
||||
const program = createProgram();
|
||||
const parsePromise = program.parseAsync(['node', 'test', 'status']);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
clientEventHandlers['connected']?.();
|
||||
|
||||
await parsePromise;
|
||||
expect(clientOptions.serverUrl).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should log CONNECTED on successful connection', async () => {
|
||||
const program = createProgram();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Command } from 'commander';
|
|||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
interface StatusOptions {
|
||||
|
|
@ -30,7 +30,7 @@ export function registerStatusCommand(program: Command) {
|
|||
|
||||
const auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
|
|
@ -50,7 +50,9 @@ export function registerStatusCommand(program: Command) {
|
|||
autoReconnect: false,
|
||||
gatewayUrl: gatewayUrl || OFFICIAL_GATEWAY_URL,
|
||||
logger: log,
|
||||
serverUrl: auth.serverUrl,
|
||||
token: auth.token,
|
||||
tokenType: auth.tokenType,
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
|
|
|
|||
1
apps/cli/src/constants/auth.ts
Normal file
1
apps/cli/src/constants/auth.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const CLI_API_KEY_ENV = 'LOBEHUB_CLI_API_KEY';
|
||||
|
|
@ -5,18 +5,19 @@ import path from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { loadSettings, saveSettings } from './index';
|
||||
import { loadSettings, normalizeUrl, resolveServerUrl, saveSettings } from './index';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-settings');
|
||||
const settingsDir = path.join(tmpDir, '.lobehub');
|
||||
const settingsFile = path.join(settingsDir, 'settings.json');
|
||||
const originalServer = process.env.LOBEHUB_SERVER;
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual['default'],
|
||||
...actual.default,
|
||||
homedir: () => path.join(os.tmpdir(), 'lobehub-cli-test-settings'),
|
||||
},
|
||||
};
|
||||
|
|
@ -31,10 +32,12 @@ vi.mock('../utils/logger', () => ({
|
|||
describe('settings', () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
delete process.env.LOBEHUB_SERVER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
process.env.LOBEHUB_SERVER = originalServer;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -64,4 +67,28 @@ describe('settings', () => {
|
|||
expect(loadSettings()).toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Please delete this file'));
|
||||
});
|
||||
|
||||
it('should normalize trailing slashes', () => {
|
||||
expect(normalizeUrl('https://self-hosted.example.com/')).toBe(
|
||||
'https://self-hosted.example.com',
|
||||
);
|
||||
expect(normalizeUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer LOBEHUB_SERVER over settings', () => {
|
||||
saveSettings({ serverUrl: 'https://settings.example.com/' });
|
||||
process.env.LOBEHUB_SERVER = 'https://env.example.com/';
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://env.example.com');
|
||||
});
|
||||
|
||||
it('should fall back to settings then official server', () => {
|
||||
saveSettings({ serverUrl: 'https://settings.example.com/' });
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://settings.example.com');
|
||||
|
||||
fs.unlinkSync(settingsFile);
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://app.lobehub.com');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,10 +14,17 @@ const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
|||
const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function normalizeUrl(url: string | undefined): string | undefined {
|
||||
export function normalizeUrl(url: string | undefined): string | undefined {
|
||||
return url ? url.replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
export function resolveServerUrl(): string {
|
||||
const envServerUrl = normalizeUrl(process.env.LOBEHUB_SERVER);
|
||||
const settingsServerUrl = normalizeUrl(loadSettings()?.serverUrl);
|
||||
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { verifyDesktopToken } from './auth';
|
||||
import { resolveSocketAuth, verifyApiKeyToken, verifyDesktopToken } from './auth';
|
||||
import type { DeviceAttachment, Env } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
|
||||
|
|
@ -58,24 +58,25 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
|||
if (att.authenticated) return; // Already authenticated, ignore
|
||||
|
||||
try {
|
||||
const token = data.token as string;
|
||||
if (!token) throw new Error('Missing token');
|
||||
const token = data.token as string | undefined;
|
||||
const tokenType = data.tokenType as 'apiKey' | 'jwt' | 'serviceToken' | undefined;
|
||||
const serverUrl = data.serverUrl as string | undefined;
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
|
||||
let verifiedUserId: string;
|
||||
|
||||
if (token === this.env.SERVICE_TOKEN) {
|
||||
// Service token auth (for CLI debugging)
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
if (!storedUserId) throw new Error('Missing userId');
|
||||
verifiedUserId = storedUserId;
|
||||
} else {
|
||||
// JWT auth (normal desktop flow)
|
||||
const result = await verifyDesktopToken(this.env, token);
|
||||
verifiedUserId = result.userId;
|
||||
}
|
||||
const verifiedUserId = await resolveSocketAuth({
|
||||
serverUrl,
|
||||
serviceToken: this.env.SERVICE_TOKEN,
|
||||
storedUserId,
|
||||
token,
|
||||
tokenType,
|
||||
verifyApiKey: verifyApiKeyToken,
|
||||
verifyJwt: async (jwt) => {
|
||||
const result = await verifyDesktopToken(this.env, jwt);
|
||||
return { userId: result.userId };
|
||||
},
|
||||
});
|
||||
|
||||
// Verify userId matches the DO routing
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
if (storedUserId && verifiedUserId !== storedUserId) {
|
||||
throw new Error('userId mismatch');
|
||||
}
|
||||
|
|
|
|||
96
apps/device-gateway/src/auth.test.ts
Normal file
96
apps/device-gateway/src/auth.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveSocketAuth } from './auth';
|
||||
|
||||
describe('resolveSocketAuth', () => {
|
||||
it('rejects missing token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing token');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the real service token when storedUserId is missing', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing userId');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects clients that only self-declare serviceToken mode', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockRejectedValue(new Error('invalid jwt'));
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'attacker-token',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('invalid jwt');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('attacker-token');
|
||||
});
|
||||
|
||||
it('treats a forged serviceToken claim with a valid JWT as JWT auth', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockResolvedValue({ userId: 'user-123' });
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'valid-jwt',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('valid-jwt');
|
||||
});
|
||||
|
||||
it('accepts the real service token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,26 @@ import type { Env } from './types';
|
|||
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
|
||||
interface CurrentUserResponse {
|
||||
data?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSocketAuthOptions {
|
||||
serverUrl?: string;
|
||||
serviceToken: string;
|
||||
storedUserId?: string;
|
||||
token?: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
verifyApiKey: (serverUrl: string, token: string) => Promise<{ userId: string }>;
|
||||
verifyJwt: (token: string) => Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
async function getPublicKey(env: Env): Promise<CryptoKey> {
|
||||
if (cachedKey) return cachedKey;
|
||||
|
||||
|
|
@ -34,3 +54,57 @@ export async function verifyDesktopToken(
|
|||
userId: payload.sub,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyApiKeyToken(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<{ userId: string }> {
|
||||
const normalizedServerUrl = new URL(serverUrl).toString().replace(/\/$/, '');
|
||||
|
||||
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
let body: CurrentUserResponse | undefined;
|
||||
try {
|
||||
body = (await response.json()) as CurrentUserResponse;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
|
||||
}
|
||||
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new Error(
|
||||
body?.error || body?.message || `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = body?.data?.id || body?.data?.userId;
|
||||
if (!userId) {
|
||||
throw new Error('Current user response did not include a user id.');
|
||||
}
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
export async function resolveSocketAuth(options: ResolveSocketAuthOptions): Promise<string> {
|
||||
const { serverUrl, serviceToken, storedUserId, token, tokenType, verifyApiKey, verifyJwt } =
|
||||
options;
|
||||
|
||||
if (!token) throw new Error('Missing token');
|
||||
|
||||
if (tokenType === 'apiKey') {
|
||||
if (!serverUrl) throw new Error('Missing serverUrl');
|
||||
const result = await verifyApiKey(serverUrl, token);
|
||||
return result.userId;
|
||||
}
|
||||
|
||||
if (token === serviceToken) {
|
||||
if (!storedUserId) throw new Error('Missing userId');
|
||||
return storedUserId;
|
||||
}
|
||||
|
||||
const result = await verifyJwt(token);
|
||||
return result.userId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export interface DeviceAttachment {
|
|||
|
||||
// Desktop → CF
|
||||
export interface AuthMessage {
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
type: 'auth';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,25 @@
|
|||
import { generateApiKey, isApiKeyExpired, validateApiKeyFormat } from '@lobechat/utils/apiKey';
|
||||
import { hashApiKey } from '@lobechat/utils/server';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
|
||||
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
||||
import { generateApiKey, isApiKeyExpired, validateApiKeyFormat } from '@/utils/apiKey';
|
||||
import { hashApiKey } from '@/utils/server/apiKeyHash';
|
||||
|
||||
import type { ApiKeyItem, NewApiKeyItem } from '../schemas';
|
||||
import { apiKeys } from '../schemas';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class ApiKeyModel {
|
||||
static findByKey = async (db: LobeChatDatabase, key: string) => {
|
||||
if (!validateApiKeyFormat(key)) {
|
||||
return null;
|
||||
}
|
||||
const keyHash = hashApiKey(key);
|
||||
|
||||
return db.query.apiKeys.findFirst({
|
||||
where: eq(apiKeys.keyHash, keyHash),
|
||||
});
|
||||
};
|
||||
|
||||
private userId: string;
|
||||
private db: LobeChatDatabase;
|
||||
private gateKeeperPromise: Promise<KeyVaultsGateKeeper> | null = null;
|
||||
|
|
@ -75,14 +86,7 @@ export class ApiKeyModel {
|
|||
};
|
||||
|
||||
findByKey = async (key: string) => {
|
||||
if (!validateApiKeyFormat(key)) {
|
||||
return null;
|
||||
}
|
||||
const keyHash = hashApiKey(key);
|
||||
|
||||
return this.db.query.apiKeys.findFirst({
|
||||
where: eq(apiKeys.keyHash, keyHash),
|
||||
});
|
||||
return ApiKeyModel.findByKey(this.db, key);
|
||||
};
|
||||
|
||||
validateKey = async (key: string) => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ describe('GatewayClient', () => {
|
|||
autoReconnect: false,
|
||||
deviceId: 'test-device-id',
|
||||
gatewayUrl: 'https://gateway.test.com',
|
||||
serverUrl: 'https://app.test.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'apiKey',
|
||||
userId: 'test-user',
|
||||
});
|
||||
});
|
||||
|
|
@ -88,6 +90,16 @@ describe('GatewayClient', () => {
|
|||
expect(client.connectionStatus).toBe('authenticating');
|
||||
expect(statusChanges).toContain('connecting');
|
||||
expect(statusChanges).toContain('authenticating');
|
||||
|
||||
const ws = (client as any).ws;
|
||||
expect(ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
serverUrl: 'https://app.test.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'apiKey',
|
||||
type: 'auth',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not reconnect if already connected', async () => {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export interface GatewayClientOptions {
|
|||
deviceId?: string;
|
||||
gatewayUrl?: string;
|
||||
logger?: GatewayClientLogger;
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -58,15 +60,19 @@ export class GatewayClient extends EventEmitter {
|
|||
private deviceId: string;
|
||||
private gatewayUrl: string;
|
||||
private token: string;
|
||||
private tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
private userId?: string;
|
||||
private serverUrl?: string;
|
||||
private logger: GatewayClientLogger;
|
||||
private autoReconnect: boolean;
|
||||
|
||||
constructor(options: GatewayClientOptions) {
|
||||
super();
|
||||
this.token = options.token;
|
||||
this.tokenType = options.tokenType;
|
||||
this.gatewayUrl = options.gatewayUrl || DEFAULT_GATEWAY_URL;
|
||||
this.deviceId = options.deviceId || randomUUID();
|
||||
this.serverUrl = options.serverUrl;
|
||||
this.userId = options.userId;
|
||||
this.logger = options.logger || noopLogger;
|
||||
this.autoReconnect = options.autoReconnect ?? true;
|
||||
|
|
@ -180,7 +186,12 @@ export class GatewayClient extends EventEmitter {
|
|||
this.setStatus('authenticating');
|
||||
|
||||
// Send token as first message instead of in URL
|
||||
this.sendMessage({ type: 'auth', token: this.token });
|
||||
this.sendMessage({
|
||||
serverUrl: this.serverUrl,
|
||||
token: this.token,
|
||||
tokenType: this.tokenType,
|
||||
type: 'auth',
|
||||
});
|
||||
};
|
||||
|
||||
private handleMessage = (data: WebSocket.Data) => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ export interface DeviceSystemInfo {
|
|||
|
||||
// Client → Server
|
||||
export interface AuthMessage {
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
type: 'auth';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createContextInner } from './context';
|
||||
import { ApiKeyModel } from '@/database/models/apiKey';
|
||||
|
||||
import { createContextInner, createLambdaContext } from './context';
|
||||
|
||||
const {
|
||||
mockExtractTraceContext,
|
||||
mockFindByKey,
|
||||
mockGetSession,
|
||||
mockUpdateLastUsed,
|
||||
mockValidateOIDCJWT,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExtractTraceContext: vi.fn(),
|
||||
mockFindByKey: vi.fn(),
|
||||
mockGetSession: vi.fn(),
|
||||
mockUpdateLastUsed: vi.fn(),
|
||||
mockValidateOIDCJWT: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/auth', () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mockGetSession,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/apiKey', () => ({
|
||||
ApiKeyModel: Object.assign(
|
||||
vi.fn().mockImplementation((_db: unknown, userId: string) => ({
|
||||
updateLastUsed: userId ? mockUpdateLastUsed : vi.fn(),
|
||||
})),
|
||||
{
|
||||
findByKey: mockFindByKey,
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/auth', () => ({
|
||||
LOBE_CHAT_AUTH_HEADER: 'X-lobe-chat-auth',
|
||||
LOBE_CHAT_OIDC_AUTH_HEADER: 'Oidc-Auth',
|
||||
authEnv: {
|
||||
ENABLE_OIDC: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/observability/traceparent', () => ({
|
||||
extractTraceContext: mockExtractTraceContext,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/oidc-provider/jwt', () => ({
|
||||
validateOIDCJWT: mockValidateOIDCJWT,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/apiKey', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return {
|
||||
...actual,
|
||||
isApiKeyExpired: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
describe('createContextInner', () => {
|
||||
it('should create context with default values when no params provided', async () => {
|
||||
|
|
@ -101,3 +165,72 @@ describe('createContextInner', () => {
|
|||
expect(ctx.traceContext).toBe(traceContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLambdaContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExtractTraceContext.mockReturnValue(undefined);
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'session-user' } });
|
||||
mockValidateOIDCJWT.mockResolvedValue({
|
||||
tokenData: { sub: 'oidc-user' },
|
||||
userId: 'oidc-user',
|
||||
});
|
||||
mockUpdateLastUsed.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should authenticate with API key and skip session fallback', async () => {
|
||||
const apiKeyRecord = {
|
||||
accessedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
enabled: true,
|
||||
expiresAt: null,
|
||||
id: 'key-1',
|
||||
key: 'encrypted-key',
|
||||
keyHash: 'hashed-key',
|
||||
lastUsedAt: null,
|
||||
name: 'Test API Key',
|
||||
updatedAt: new Date(),
|
||||
userId: 'api-user',
|
||||
} satisfies NonNullable<Awaited<ReturnType<typeof ApiKeyModel.findByKey>>>;
|
||||
|
||||
vi.mocked(ApiKeyModel.findByKey).mockResolvedValue(apiKeyRecord);
|
||||
|
||||
const request = new NextRequest('https://example.com/trpc/lambda', {
|
||||
headers: {
|
||||
'X-API-Key': 'sk-lh-aaaaaaaaaaaaaaaa',
|
||||
},
|
||||
});
|
||||
|
||||
const context = await createLambdaContext(request);
|
||||
|
||||
expect(context.userId).toBe('api-user');
|
||||
expect(mockGetSession).not.toHaveBeenCalled();
|
||||
expect(mockValidateOIDCJWT).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid API key without falling back to OIDC or session', async () => {
|
||||
vi.mocked(ApiKeyModel.findByKey).mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest('https://example.com/trpc/lambda', {
|
||||
headers: {
|
||||
'Oidc-Auth': 'oidc-token',
|
||||
'X-API-Key': 'sk-lh-bbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
const context = await createLambdaContext(request);
|
||||
|
||||
expect(context.userId).toBeNull();
|
||||
expect(mockValidateOIDCJWT).not.toHaveBeenCalled();
|
||||
expect(mockGetSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use session auth when no API key header is present', async () => {
|
||||
const request = new NextRequest('https://example.com/trpc/lambda');
|
||||
|
||||
const context = await createLambdaContext(request);
|
||||
|
||||
expect(context.userId).toBe('session-user');
|
||||
expect(mockGetSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import debug from 'debug';
|
|||
import { type NextRequest } from 'next/server';
|
||||
|
||||
import { auth } from '@/auth';
|
||||
import { getServerDB } from '@/database/core/db-adaptor';
|
||||
import { ApiKeyModel } from '@/database/models/apiKey';
|
||||
import { authEnv, LOBE_CHAT_AUTH_HEADER, LOBE_CHAT_OIDC_AUTH_HEADER } from '@/envs/auth';
|
||||
import { extractTraceContext } from '@/libs/observability/traceparent';
|
||||
import { validateOIDCJWT } from '@/libs/oidc-provider/jwt';
|
||||
import { isApiKeyExpired, validateApiKeyFormat } from '@/utils/apiKey';
|
||||
|
||||
// Create context logger namespace
|
||||
const log = debug('lobe-trpc:lambda:context');
|
||||
const LOBE_CHAT_API_KEY_HEADER = 'X-API-Key';
|
||||
|
||||
const extractClientIp = (request: NextRequest): string | undefined => {
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
|
|
@ -25,6 +29,31 @@ const extractClientIp = (request: NextRequest): string | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const validateApiKeyUserId = async (apiKey: string): Promise<string | null> => {
|
||||
if (!validateApiKeyFormat(apiKey)) return null;
|
||||
|
||||
try {
|
||||
const db = await getServerDB();
|
||||
const apiKeyRecord = await ApiKeyModel.findByKey(db, apiKey);
|
||||
|
||||
if (!apiKeyRecord) return null;
|
||||
if (!apiKeyRecord.enabled) return null;
|
||||
if (isApiKeyExpired(apiKeyRecord.expiresAt)) return null;
|
||||
|
||||
const userApiKeyModel = new ApiKeyModel(db, apiKeyRecord.userId);
|
||||
void userApiKeyModel.updateLastUsed(apiKeyRecord.id).catch((error) => {
|
||||
log('Failed to update API key last used timestamp: %O', error);
|
||||
console.error('Failed to update API key last used timestamp:', error);
|
||||
});
|
||||
|
||||
return apiKeyRecord.userId;
|
||||
} catch (error) {
|
||||
log('API key authentication failed: %O', error);
|
||||
console.error('API key authentication failed, trying other methods:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export interface OIDCAuth {
|
||||
// Other OIDC information that might be needed (optional, as payload contains all info)
|
||||
[key: string]: any;
|
||||
|
|
@ -117,6 +146,31 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
|
|||
};
|
||||
log('LobeChat Authorization header: %s', authorization ? 'exists' : 'not found');
|
||||
|
||||
const apiKeyToken = request.headers.get(LOBE_CHAT_API_KEY_HEADER)?.trim();
|
||||
log('X-API-Key header: %s', apiKeyToken ? 'exists' : 'not found');
|
||||
|
||||
if (apiKeyToken) {
|
||||
const apiKeyUserId = await validateApiKeyUserId(apiKeyToken);
|
||||
|
||||
if (!apiKeyUserId) {
|
||||
log('API key authentication failed; rejecting request without fallback auth');
|
||||
|
||||
return createContextInner({
|
||||
...commonContext,
|
||||
traceContext,
|
||||
userId: null,
|
||||
});
|
||||
}
|
||||
|
||||
log('API key authentication successful, userId: %s', apiKeyUserId);
|
||||
|
||||
return createContextInner({
|
||||
...commonContext,
|
||||
traceContext,
|
||||
userId: apiKeyUserId,
|
||||
});
|
||||
}
|
||||
|
||||
let userId;
|
||||
let oidcAuth;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue