feat(auth): unified IAM user-token tenancy; delete guest/shared-key cruft
This commit is contained in:
@@ -12,8 +12,8 @@ const {
|
||||
loadWebSearchAuth,
|
||||
buildImageToolContext,
|
||||
buildWebSearchContext,
|
||||
resolveHanzoCloudKey,
|
||||
isHanzoPerUserKeyEnabled,
|
||||
resolveTenantBearer,
|
||||
OPENID_BEARER_SENTINEL,
|
||||
} = require('@hanzochat/api');
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
const {
|
||||
@@ -243,18 +243,17 @@ const loadTools = async ({
|
||||
customConstructors.dalle = async () => {
|
||||
const authFields = getAuthFields('dalle');
|
||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
||||
const billingUser = options.req?.user;
|
||||
const isAuthenticatedUser = Boolean(
|
||||
billingUser && !billingUser.guest && billingUser.email,
|
||||
);
|
||||
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
|
||||
const perUserKey = await resolveHanzoCloudKey(billingUser);
|
||||
if (!perUserKey) {
|
||||
throw new Error(
|
||||
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
|
||||
);
|
||||
// Canonical Hanzo Cloud billing (mirrors custom/initialize.ts): when the
|
||||
// image endpoint is configured to forward the user's IAM bearer
|
||||
// (DALLE3_API_KEY === the OIDC-token sentinel), resolve the signed-in
|
||||
// user's own IAM token and forward it so cloud meters THEIR org. Fail
|
||||
// closed if there is no forwardable bearer — no shared key to spend.
|
||||
if (authValues.DALLE3_API_KEY === OPENID_BEARER_SENTINEL) {
|
||||
const bearer = resolveTenantBearer(options.req);
|
||||
if (!bearer) {
|
||||
throw new Error('Sign in with Hanzo to generate images — your Hanzo account funds this request.');
|
||||
}
|
||||
authValues.DALLE3_API_KEY = perUserKey;
|
||||
authValues.DALLE3_API_KEY = bearer;
|
||||
}
|
||||
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { billingSubject } = require('@hanzochat/api');
|
||||
const { createAutoRefillTransaction } = require('./Transaction');
|
||||
const { logViolation } = require('~/cache');
|
||||
const { getMultiplier } = require('./tx');
|
||||
@@ -95,26 +94,12 @@ const checkBalanceRecord = async function ({
|
||||
const multiplier = getMultiplier({ valueKey, tokenType, model, endpoint, endpointTokenConfig });
|
||||
const tokenCost = amount * multiplier;
|
||||
|
||||
// Guests (anonymous preview) are NOT balance-gated here. Their spend is bounded
|
||||
// two ways, neither of which is an authed user's org balance: (1) the per-IP
|
||||
// guest message limiter (GUEST_MESSAGE_MAX, default 3) and (2) the separate,
|
||||
// small-capped, NON-exempt guest key (HANZO_API_KEY) whose own org's Commerce
|
||||
// balance the cloud gateway debits and 402s when empty. Running them through the
|
||||
// Commerce/local gate (startBalance:0) would block the free tier entirely.
|
||||
if (req?.user?.guest === true) {
|
||||
return { canSpend: true, balance: 0, tokenCost };
|
||||
}
|
||||
|
||||
const commerceClient = getCommerceClient();
|
||||
const billingOrg = (req?.user?.organization ?? '').toString().trim();
|
||||
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
|
||||
// the authoritative IAM identity; otherwise derive it from organization + email
|
||||
// (name == email for individual signups). This keys the gate on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
|
||||
// the shared org balance.
|
||||
const subject =
|
||||
(req?.user?.billingSubject ?? '').toString().trim() ||
|
||||
billingSubject(billingOrg, req?.user?.email);
|
||||
// Tenant billing key = the org (the token `owner`). cloud is the authoritative
|
||||
// meter+gate and bills per-org (see AUTH_BILLING_CONTRACT.md); this optional
|
||||
// local pre-flight keys on the SAME org so it never diverges from cloud. This
|
||||
// gate is OFF in production (balance.enabled=false) — cloud is the ONE gate.
|
||||
const subject = (req?.user?.organization ?? '').toString().trim();
|
||||
|
||||
// Commerce-first authoritative gate (per-subject, fail closed).
|
||||
if (commerceClient && subject) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
const { buildGuestEndpointsConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
@@ -72,9 +72,6 @@ const updateFavoritesController = async (req, res) => {
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
const { buildGuestModelsConfig } = require('~/server/services/guestConfig');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,6 @@ async function loadModels(req) {
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(buildGuestModelsConfig());
|
||||
}
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -40,16 +40,12 @@ const { invalidateCachedTools } = require('~/server/services/Config/getCachedToo
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { buildGuestUser } = require('~/server/services/guestConfig');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
/** @type {IUser} */
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getGuestConfig, GUEST_ROLE } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Issues a short-lived guest token for anonymous preview chat.
|
||||
*
|
||||
* The token is signed with `JWT_SECRET` and carries a `guest: true` claim plus a
|
||||
* per-token random id, so guest principals are ephemeral and isolated from each
|
||||
* other. It is rejected by the standard `jwt` strategy (no matching DB user),
|
||||
* which keeps every non-chat route closed to guests.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
const guestTokenController = async (req, res) => {
|
||||
const config = getGuestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.error('[guestTokenController] JWT_SECRET is not configured');
|
||||
return res.status(500).json({ message: 'Server misconfiguration' });
|
||||
}
|
||||
|
||||
const id = `guest_${randomUUID()}`;
|
||||
const expiresInSeconds = Math.floor(config.tokenExpiryMs / 1000);
|
||||
|
||||
const token = jwt.sign({ id, guest: true, role: GUEST_ROLE }, process.env.JWT_SECRET, {
|
||||
expiresIn: expiresInSeconds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
token,
|
||||
expiresIn: expiresInSeconds,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
messageMax: config.messageMax,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { guestTokenController };
|
||||
@@ -1,118 +0,0 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
describe('enforceGuestScope', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
messageMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes non-guest requests through untouched', () => {
|
||||
const req = { user: { id: 'u1', role: 'USER' }, body: { endpoint: 'OpenAI', model: 'gpt-4o' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('OpenAI');
|
||||
expect(req.body.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different endpoint (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'OpenAI' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a guest naming a different (paid) model (403)', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { endpoint: 'Hanzo', model: 'zen4-max' } };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pins endpoint, type, and model for a compliant guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: { endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' },
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.endpointType).toBe('custom');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('pins endpoint/model even when the guest omits them', () => {
|
||||
const req = { user: { id: 'g1', guest: true }, body: { text: 'hi' } };
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.endpoint).toBe('Hanzo');
|
||||
expect(req.body.model).toBe('zen3-nano');
|
||||
});
|
||||
|
||||
it('strips every privileged capability from a guest request', () => {
|
||||
const req = {
|
||||
user: { id: 'g1', guest: true },
|
||||
body: {
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
agent_id: 'agent_evil',
|
||||
spec: 'paid-spec',
|
||||
preset: { foo: 'bar' },
|
||||
files: [{ file_id: 'f1' }],
|
||||
tools: ['execute_code'],
|
||||
tool_resources: { x: 1 },
|
||||
resendFiles: true,
|
||||
promptPrefix: 'jailbreak',
|
||||
web_search: true,
|
||||
},
|
||||
};
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, mockRes(), next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.body.agent_id).toBeUndefined();
|
||||
expect(req.body.spec).toBeUndefined();
|
||||
expect(req.body.preset).toBeUndefined();
|
||||
expect(req.body.files).toBeUndefined();
|
||||
expect(req.body.tools).toBeUndefined();
|
||||
expect(req.body.tool_resources).toBeUndefined();
|
||||
expect(req.body.resendFiles).toBeUndefined();
|
||||
expect(req.body.promptPrefix).toBeUndefined();
|
||||
expect(req.body.web_search).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 404 for a guest when guest chat is disabled mid-flight', () => {
|
||||
getGuestConfig.mockReturnValue({ enabled: false, endpoint: 'Hanzo', model: 'zen3-nano' });
|
||||
const req = { user: { id: 'g1', guest: true }, body: {} };
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
enforceGuestScope(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Integration test for the guest chat middleware chain composition:
|
||||
* guest auth -> scope enforcement -> quota, plus the reserved-subpath guard
|
||||
* that keeps management routes (abort/active/...) on the JWT-only parent router.
|
||||
*/
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom', agents: 'agents' },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () =>
|
||||
jest.fn((req, res, next) => {
|
||||
req.user = { id: 'real-user', role: 'USER' };
|
||||
next();
|
||||
}),
|
||||
);
|
||||
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('../enforceGuestScope');
|
||||
const { guestMessageLimiter } = require('../limiters/guestMessageLimiter');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const buildRouter = () => {
|
||||
const router = express.Router();
|
||||
const RESERVED = new Set(['abort', 'active']);
|
||||
|
||||
const chatRouter = express.Router();
|
||||
chatRouter.use((req, res, next) => {
|
||||
const subpath = req.path.split('/').filter(Boolean)[0];
|
||||
if (RESERVED.has(subpath)) {
|
||||
return next('router');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
chatRouter.post('/', (req, res) =>
|
||||
res
|
||||
.status(200)
|
||||
.json({ endpoint: req.body.endpoint, model: req.body.model, role: req.user.role }),
|
||||
);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// JWT-only management route on the parent (after the guest router)
|
||||
router.post('/chat/abort', require('../requireJwtAuth'), (req, res) =>
|
||||
res.status(200).json({ aborted: true, role: req.user.role }),
|
||||
);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/v1/chat/agents', buildRouter());
|
||||
return app;
|
||||
};
|
||||
|
||||
const guestToken = () => jwt.sign({ id: 'guest_1', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
describe('guest chat middleware chain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_MESSAGE_MAX = '2';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen3-nano';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('lets a guest reach the completion route, pinned to the free endpoint/model', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/v1/chat/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.1')
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ endpoint: 'Hanzo', model: 'zen3-nano', role: 'GUEST' });
|
||||
});
|
||||
|
||||
it('returns 402 GUEST_LIMIT after the quota is exhausted', async () => {
|
||||
const app = buildApp();
|
||||
const ip = '10.1.0.2';
|
||||
const send = () =>
|
||||
request(app)
|
||||
.post('/v1/chat/agents/chat')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', ip)
|
||||
.send({ endpoint: 'Hanzo', model: 'zen3-nano', text: 'hi' });
|
||||
|
||||
expect((await send()).status).toBe(200);
|
||||
expect((await send()).status).toBe(200);
|
||||
const blocked = await send();
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('routes reserved /chat/abort to the JWT-only handler, not the guest router', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/v1/chat/agents/chat/abort')
|
||||
.set('Authorization', `Bearer ${guestToken()}`)
|
||||
.set('x-test-ip', '10.1.0.3')
|
||||
.send({ streamId: 'x' });
|
||||
// requireJwtAuth mock forces a real USER; guest never reaches abort.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ aborted: true, role: 'USER' });
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../requireJwtAuth', () => jest.fn((req, res, next) => next('jwt-fallback')));
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('../requireGuestOrJwtAuth');
|
||||
|
||||
const JWT_SECRET = 'test-guest-secret';
|
||||
|
||||
const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() });
|
||||
|
||||
const guestToken = (claims = {}) =>
|
||||
jwt.sign({ id: 'guest_abc', guest: true, role: 'GUEST', ...claims }, JWT_SECRET);
|
||||
|
||||
describe('requireGuestOrJwtAuth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest chat is disabled', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'false';
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates a valid guest token as an ephemeral GUEST principal', () => {
|
||||
const req = { headers: { authorization: `Bearer ${guestToken()}` } };
|
||||
const next = jest.fn();
|
||||
requireGuestOrJwtAuth(req, mockRes(), next);
|
||||
expect(requireJwtAuth).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.user).toEqual({ id: 'guest_abc', role: 'GUEST', name: 'Guest', guest: true });
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when no bearer token is present', () => {
|
||||
const req = { headers: {} };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a non-guest (regular) JWT', () => {
|
||||
const userToken = jwt.sign({ id: 'real-user' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${userToken}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth for a token signed with the wrong secret (fail closed)', () => {
|
||||
const forged = jwt.sign({ id: 'guest_x', guest: true }, 'wrong-secret');
|
||||
const req = { headers: { authorization: `Bearer ${forged}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest claim is missing', () => {
|
||||
const token = jwt.sign({ id: 'guest_x' }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to requireJwtAuth when guest token lacks an id', () => {
|
||||
const token = jwt.sign({ guest: true }, JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
requireGuestOrJwtAuth(req, mockRes(), jest.fn());
|
||||
expect(requireJwtAuth).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Server-side capability scope for guest principals.
|
||||
*
|
||||
* Runs after guest authentication and before `buildEndpointOption`. For guest
|
||||
* requests it pins the endpoint and model to the configured free Zen endpoint and
|
||||
* strips every capability a guest must not reach (paid models, agents, tools,
|
||||
* files, model specs, presets). The client is never trusted: any guest request
|
||||
* that names a different endpoint/model is rejected rather than silently rewritten.
|
||||
*
|
||||
* Non-guest requests pass through untouched.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const enforceGuestScope = (req, res, next) => {
|
||||
if (req.user?.guest !== true) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return res.status(404).json({ message: 'Guest chat is not enabled' });
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
const { endpoint, model } = body;
|
||||
|
||||
if (endpoint != null && endpoint !== config.endpoint) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected endpoint: ${endpoint}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
if (model != null && model !== config.model) {
|
||||
logger.warn(`[enforceGuestScope] Guest ${req.user.id} rejected model: ${model}`);
|
||||
return res.status(403).json({ message: 'Guests may only use the free preview model' });
|
||||
}
|
||||
|
||||
body.endpoint = config.endpoint;
|
||||
body.endpointType = EModelEndpoint.custom;
|
||||
body.model = config.model;
|
||||
|
||||
delete body.agent_id;
|
||||
delete body.spec;
|
||||
delete body.preset;
|
||||
delete body.files;
|
||||
delete body.tools;
|
||||
delete body.tool_resources;
|
||||
delete body.resendFiles;
|
||||
delete body.promptPrefix;
|
||||
delete body.web_search;
|
||||
|
||||
req.body = body;
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = enforceGuestScope;
|
||||
@@ -10,8 +10,6 @@ const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
const checkInviteUser = require('./checkInviteUser');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('./requireGuestOrJwtAuth');
|
||||
const enforceGuestScope = require('./enforceGuestScope');
|
||||
const configMiddleware = require('./config/app');
|
||||
const validateModel = require('./validateModel');
|
||||
const moderateText = require('./moderateText');
|
||||
@@ -38,8 +36,6 @@ module.exports = {
|
||||
moderateText,
|
||||
validateModel,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
enforceGuestScope,
|
||||
checkInviteUser,
|
||||
requireLdapAuth,
|
||||
requireLocalAuth,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
|
||||
const parsePositiveInt = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const GUEST_TOKEN_WINDOW = parsePositiveInt(process.env.GUEST_TOKEN_WINDOW, 60);
|
||||
const GUEST_TOKEN_MAX = parsePositiveInt(process.env.GUEST_TOKEN_MAX, 20);
|
||||
|
||||
const windowMs = GUEST_TOKEN_WINDOW * 60 * 1000;
|
||||
const max = GUEST_TOKEN_MAX;
|
||||
const windowInMinutes = windowMs / 60000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(429).json({
|
||||
message: `Too many guest sessions, please try again after ${windowInMinutes} minutes.`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for guest token issuance.
|
||||
*
|
||||
* Caps how many guest tokens a single client IP can mint per window so nobody can
|
||||
* spam-mint tokens (a DoS / quota-probe vector). Keyed on the REAL client IP
|
||||
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`) and backed by the shared
|
||||
* Redis `limiterCache` so the cap holds across replicas. Note this is defense in
|
||||
* depth only: even unlimited tokens cannot multiply the message quota, which is
|
||||
* itself keyed per-IP (see `guestMessageLimiter`).
|
||||
*/
|
||||
const guestTokenLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
store: limiterCache('guest_token_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestTokenLimiter };
|
||||
@@ -1,36 +0,0 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@hanzochat/api');
|
||||
const { guestClientIp } = require('~/server/utils');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
|
||||
const GUEST_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const handler = (req, res) => {
|
||||
return res.status(402).json({
|
||||
type: 'GUEST_LIMIT',
|
||||
message: 'Guest message limit reached. Log in to continue.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-IP quota limiter for anonymous guest chat completions.
|
||||
*
|
||||
* Reuses the shared Redis-backed `limiterCache` infrastructure (same store as the
|
||||
* message limiters), so the count is shared across replicas — a guest cannot
|
||||
* multiply their quota by round-robining pods. The key is the REAL client IP
|
||||
* (`guestClientIp` → Cloudflare `CF-Connecting-IP`), NOT the guest token, so
|
||||
* clearing cookies, opening incognito, or minting a fresh guest token does not
|
||||
* reset the count. It only counts requests made by an authenticated guest
|
||||
* principal; logged-in users skip the counter entirely. On exhaustion it returns
|
||||
* a `402 { type: 'GUEST_LIMIT' }` signal the client maps to the login gate.
|
||||
*/
|
||||
const guestMessageLimiter = rateLimit({
|
||||
windowMs: GUEST_LIMIT_WINDOW_MS,
|
||||
max: () => getGuestConfig().messageMax,
|
||||
handler,
|
||||
keyGenerator: guestClientIp,
|
||||
skip: (req) => req.user?.guest !== true,
|
||||
store: limiterCache('guest_message_limiter'),
|
||||
});
|
||||
|
||||
module.exports = { guestMessageLimiter };
|
||||
@@ -1,78 +0,0 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
limiterCache: () => undefined,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/utils', () => ({
|
||||
removePorts: (req) => req.headers['x-test-ip'] || req.ip,
|
||||
guestClientIp: (req) =>
|
||||
req.headers['cf-connecting-ip'] || req.headers['x-test-ip'] || req.ip,
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/guestConfig', () => ({
|
||||
getGuestConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
|
||||
const buildApp = (user) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
app.use(guestMessageLimiter);
|
||||
app.post('/chat', (_req, res) => res.status(200).json({ ok: true }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guestMessageLimiter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getGuestConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
messageMax: 3,
|
||||
endpoint: 'Hanzo',
|
||||
model: 'zen3-nano',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows up to GUEST_MESSAGE_MAX guest messages then returns 402 GUEST_LIMIT', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
const ip = '10.0.0.1';
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', ip);
|
||||
expect(blocked.status).toBe(402);
|
||||
expect(blocked.body.type).toBe('GUEST_LIMIT');
|
||||
});
|
||||
|
||||
it('counts quota per IP independently', async () => {
|
||||
const app = buildApp({ id: 'g1', guest: true });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
}
|
||||
const blocked = await request(app).post('/chat').set('x-test-ip', '10.0.0.2');
|
||||
expect(blocked.status).toBe(402);
|
||||
|
||||
const fresh = await request(app).post('/chat').set('x-test-ip', '10.0.0.3');
|
||||
expect(fresh.status).toBe(200);
|
||||
});
|
||||
|
||||
it('never counts or blocks authenticated (non-guest) users', async () => {
|
||||
const app = buildApp({ id: 'real-user', role: 'USER' });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app).post('/chat').set('x-test-ip', '10.0.0.4');
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,6 @@ const createTTSLimiters = require('./ttsLimiters');
|
||||
const createSTTLimiters = require('./sttLimiters');
|
||||
|
||||
const loginLimiter = require('./loginLimiter');
|
||||
const { guestTokenLimiter } = require('./guestLimiters');
|
||||
const { guestMessageLimiter } = require('./guestMessageLimiter');
|
||||
const importLimiters = require('./importLimiters');
|
||||
const uploadLimiters = require('./uploadLimiters');
|
||||
const forkLimiters = require('./forkLimiters');
|
||||
@@ -20,8 +18,6 @@ module.exports = {
|
||||
...messageLimiters,
|
||||
...forkLimiters,
|
||||
loginLimiter,
|
||||
guestTokenLimiter,
|
||||
guestMessageLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
cloudAgentLimiter,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const requireJwtAuth = require('./requireJwtAuth');
|
||||
const { getGuestConfig, buildGuestPrincipal } = require('~/server/services/guestConfig');
|
||||
|
||||
/**
|
||||
* Extracts a bearer token from the Authorization header.
|
||||
* @param {ServerRequest} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
const getBearerToken = (req) => {
|
||||
const header = req.headers?.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return header.slice('Bearer '.length).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest-aware authentication, applied ONLY to chat-completion routes.
|
||||
*
|
||||
* When `ALLOW_GUEST_CHAT` is enabled and the bearer token is a valid guest token
|
||||
* (`guest: true`, signed with `JWT_SECRET`), an ephemeral guest principal is set
|
||||
* on `req.user` and the request proceeds. Otherwise the request falls through to
|
||||
* the standard `requireJwtAuth`, which rejects guest tokens everywhere else
|
||||
* (the `jwt` strategy requires a matching DB user). Fail closed by construction.
|
||||
*
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
const requireGuestOrJwtAuth = (req, res, next) => {
|
||||
const config = getGuestConfig();
|
||||
if (!config.enabled) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
const token = getBearerToken(req);
|
||||
if (!token) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (_err) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
if (payload?.guest !== true || !payload.id) {
|
||||
return requireJwtAuth(req, res, next);
|
||||
}
|
||||
|
||||
req.user = buildGuestPrincipal(payload.id);
|
||||
logger.debug(`[requireGuestOrJwtAuth] Guest principal authenticated: ${payload.id}`);
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = requireGuestOrJwtAuth;
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* End-to-end auth chain for guest bootstrap: real passport `jwt` strategy +
|
||||
* real `requireGuestOrJwtAuth` / `requireJwtAuth` + real controllers, driven by
|
||||
* a real signed guest JWT.
|
||||
*
|
||||
* Proves:
|
||||
* - a guest token on a JWT-only route returns a clean 401 (NOT 500/CastError),
|
||||
* - /v1/chat/endpoints, /v1/chat/user, /v1/chat/convos return guest-scoped data,
|
||||
* - a non-bootstrap protected route still 401s for a guest.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
SystemRoles: { USER: 'USER' },
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
|
||||
// getUserById would throw a Mongoose CastError on a guest id; assert it is never
|
||||
// reached for a guest, and returns null (→ clean 401) for a real-but-missing id.
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: jest.fn(async (id) => {
|
||||
if (String(id).startsWith('guest_')) {
|
||||
throw new Error('CastError: Cast to ObjectId failed for value "' + id + '"');
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
updateUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getUserById } = require('~/models');
|
||||
const jwtLogin = require('~/strategies/jwtStrategy');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { buildGuestEndpointsConfig, buildGuestUser } = require('~/server/services/guestConfig');
|
||||
|
||||
const JWT_SECRET = 'integration-guest-secret';
|
||||
|
||||
const buildApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(passport.initialize());
|
||||
passport.use(jwtLogin());
|
||||
|
||||
// Bootstrap (guest-aware) routes.
|
||||
app.get('/v1/chat/endpoints', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.send(JSON.stringify(buildGuestEndpointsConfig()));
|
||||
}
|
||||
return res.send(JSON.stringify({ openAI: {}, Hanzo: {} }));
|
||||
});
|
||||
app.get('/v1/chat/user', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).send(buildGuestUser(req.user));
|
||||
}
|
||||
return res.status(200).send(req.user);
|
||||
});
|
||||
app.get('/v1/chat/convos', requireGuestOrJwtAuth, (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
return res.status(200).json({ conversations: ['real'] });
|
||||
});
|
||||
|
||||
// Non-bootstrap protected route (JWT-only): must reject guests.
|
||||
app.get('/v1/chat/prompts', requireJwtAuth, (req, res) => res.status(200).json({ ok: true }));
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('guest bootstrap auth chain (integration)', () => {
|
||||
let app;
|
||||
const guestToken = jwt.sign({ id: 'guest_abc-123', guest: true, role: 'GUEST' }, JWT_SECRET);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.JWT_SECRET = JWT_SECRET;
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
app = buildApp();
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('GET /v1/chat/endpoints → 200 with ONLY the guest endpoint', async () => {
|
||||
const res = await request(app)
|
||||
.get('/v1/chat/endpoints')
|
||||
.set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = JSON.parse(res.text);
|
||||
expect(Object.keys(body)).toEqual(['Hanzo']);
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/user → 200 ephemeral guest principal (no email, no DB lookup)', async () => {
|
||||
const res = await request(app).get('/v1/chat/user').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ id: 'guest_abc-123', role: 'GUEST', guest: true });
|
||||
expect(res.body).not.toHaveProperty('email');
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/convos → 200 empty list for a guest', async () => {
|
||||
const res = await request(app).get('/v1/chat/convos').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ conversations: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts (JWT-only) → clean 401 for a guest, NEVER 500/CastError', async () => {
|
||||
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${guestToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
// The guest id must never reach getUserById (that is what caused the CastError → 500).
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts → clean 401 for a real-but-missing user (no 500)', async () => {
|
||||
const realToken = jwt.sign({ id: '64b2f0c0c0c0c0c0c0c0c0c0' }, JWT_SECRET);
|
||||
const res = await request(app).get('/v1/chat/prompts').set('Authorization', `Bearer ${realToken}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(getUserById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('GET /v1/chat/prompts → 401 with no token (fail closed)', async () => {
|
||||
const res = await request(app).get('/v1/chat/prompts');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { resolveTenantBearer } = require('@hanzochat/api');
|
||||
const { requireJwtAuth, cloudAgentLimiter } = require('~/server/middleware');
|
||||
const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/CloudAgentsClient');
|
||||
|
||||
@@ -20,85 +19,18 @@ const { getCloudAgentsClient, AGENT_NAME_RE } = require('~/server/services/Cloud
|
||||
*/
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
|
||||
* mandatory — the function only ever REMOVES a token from consideration, never
|
||||
* admits one:
|
||||
* - Decodable JWT: an opaque/garbage token cannot be principal-bound, so it is
|
||||
* never forwarded. hanzo.id — the only cloud IdP — always issues JWTs.
|
||||
* - Principal binding (MANDATORY): the token must NAME the authenticated user
|
||||
* (`sub === user.openidId`). If we cannot ASSERT the binding — no `openidId`
|
||||
* on the principal, no `sub` on the token, or a mismatch — we do NOT forward.
|
||||
* This keeps "forwarded principal == req.user" true by construction, closing
|
||||
* the credential-mixing confused deputy (a session/cookie carrying a
|
||||
* different user's token) with no fail-open when binding data is absent.
|
||||
* - Expiry: never forward a token past its own `exp`.
|
||||
*
|
||||
* Decode-only (no signature verification): cloud performs the authoritative
|
||||
* JWKS + claim validation over the SAME claims, so it runs as exactly this
|
||||
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
|
||||
* binding; an intact `sub` is rejected by cloud on signature.
|
||||
*
|
||||
* @param {string|undefined} token
|
||||
* @param {{openidId?: string}} user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isForwardableToken(token, user) {
|
||||
if (!token || !user?.openidId) {
|
||||
return false;
|
||||
}
|
||||
const claims = jwt.decode(token);
|
||||
if (!claims || typeof claims !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (claims.sub !== user.openidId) {
|
||||
return false;
|
||||
}
|
||||
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's hanzo.id bearer for the on-behalf-of call to cloud.
|
||||
*
|
||||
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
|
||||
* authoritative DB user loaded by requireJwtAuth), NOT any cookie. A local user
|
||||
* never carries a hanzo.id token, so a stale OpenID session left in the browser
|
||||
* can never be forwarded under a local identity — the confused deputy is denied
|
||||
* at the identity layer.
|
||||
*
|
||||
* EVERY candidate — session first, then the httpOnly no-session cookie fallback;
|
||||
* id_token preferred, access_token second — must pass `isForwardableToken`:
|
||||
* principal-bound to req.user and unexpired. Returns null — for an honest 401,
|
||||
* never a wrong-principal, expired, unbound, or fabricated call — otherwise.
|
||||
*
|
||||
* Delegates to the ONE canonical resolver (`resolveTenantBearer`, @hanzochat/api)
|
||||
* that the chat-completion path also uses — principal-bound to req.user,
|
||||
* unexpired, id_token preferred with an access_token fallback, session first then
|
||||
* the httpOnly cookie. Returns null for an honest 401 (never a wrong-principal,
|
||||
* expired, unbound, or fabricated call).
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getUserCloudBearer(req) {
|
||||
if (req.user?.provider !== 'openid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
||||
const session = req.session?.openidTokens;
|
||||
|
||||
const idToken = session?.idToken || parsed.openid_id_token;
|
||||
if (isForwardableToken(idToken, req.user)) {
|
||||
return idToken;
|
||||
}
|
||||
/**
|
||||
* access_token fallback (rare: the session/cookie carries no id_token).
|
||||
* hanzo.id issues JWT access tokens too, so this stays principal-bound; an
|
||||
* opaque or foreign access_token fails the gate and is never forwarded.
|
||||
*/
|
||||
const accessToken = session?.accessToken || parsed.openid_access_token;
|
||||
if (isForwardableToken(accessToken, req.user)) {
|
||||
return accessToken;
|
||||
}
|
||||
return null;
|
||||
return resolveTenantBearer(req);
|
||||
}
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
@@ -8,9 +8,6 @@ const {
|
||||
messageIpLimiter,
|
||||
configMiddleware,
|
||||
messageUserLimiter,
|
||||
enforceGuestScope,
|
||||
guestMessageLimiter,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
const { saveMessage } = require('~/models');
|
||||
const openai = require('./openai');
|
||||
@@ -38,15 +35,11 @@ router.use('/v1/responses', responses);
|
||||
router.use('/v1', openai);
|
||||
|
||||
/**
|
||||
* Guest-capable chat completion router.
|
||||
*
|
||||
* Mounted before the JWT-only routes below so anonymous guests (when
|
||||
* `ALLOW_GUEST_CHAT` is enabled) can reach ONLY the completion endpoints. Auth
|
||||
* here accepts either a guest token or a real JWT; `enforceGuestScope` then pins
|
||||
* guests to the free Zen endpoint/model and strips every other capability, and
|
||||
* `guestMessageLimiter` enforces the per-IP guest quota. Every other agents route
|
||||
* (management, CRUD, files) remains gated by the strict `requireJwtAuth` below,
|
||||
* which rejects guest tokens.
|
||||
* Chat completion router. Every request is gated by the strict `requireJwtAuth`
|
||||
* (there is no guest chat — a signed-in IAM identity is required so the request
|
||||
* bills to the user's own org via their forwarded bearer). Split from the parent
|
||||
* router only so the completion middleware chain (ban/uaParser/config/limiters)
|
||||
* applies to completions but not to management/CRUD routes.
|
||||
*/
|
||||
const RESERVED_CHAT_SUBPATHS = new Set(['stream', 'active', 'status', 'abort']);
|
||||
|
||||
@@ -63,12 +56,10 @@ chatRouter.use((req, res, next) => {
|
||||
}
|
||||
return next();
|
||||
});
|
||||
chatRouter.use(requireGuestOrJwtAuth);
|
||||
chatRouter.use(requireJwtAuth);
|
||||
chatRouter.use(checkBan);
|
||||
chatRouter.use(uaParser);
|
||||
chatRouter.use(configMiddleware);
|
||||
chatRouter.use(enforceGuestScope);
|
||||
chatRouter.use(guestMessageLimiter);
|
||||
|
||||
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||
chatRouter.use(messageIpLimiter);
|
||||
@@ -82,19 +73,6 @@ chatRouter.use('/', chat);
|
||||
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
/**
|
||||
* Guest-safe active-jobs poll. Guests never own a generation job, so this
|
||||
* returns an empty set without a DB/user lookup. Registered BEFORE the strict
|
||||
* JWT guard below so the composer's bootstrap poll doesn't 401-loop for guests;
|
||||
* every other agents route stays JWT-only and rejects guest tokens.
|
||||
*/
|
||||
router.get('/chat/active', requireGuestOrJwtAuth, async (req, res, next) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.json({ activeJobIds: [] });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
@@ -17,7 +17,6 @@ const {
|
||||
const { verify2FAWithTempToken } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { guestTokenController } = require('~/server/controllers/auth/GuestController');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const middleware = require('~/server/middleware');
|
||||
const { Balance } = require('~/db/models');
|
||||
@@ -42,7 +41,6 @@ router.post(
|
||||
loginController,
|
||||
);
|
||||
router.post('/refresh', refreshController);
|
||||
router.post('/guest', middleware.guestTokenLimiter, middleware.checkBan, guestTokenController);
|
||||
router.post(
|
||||
'/register',
|
||||
middleware.registerLimiter,
|
||||
|
||||
@@ -5,7 +5,6 @@ const { isEnabled, getBalanceConfig } = require('@hanzochat/api');
|
||||
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getGuestConfig } = require('~/server/services/guestConfig');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
@@ -60,8 +59,6 @@ router.get('/', async function (req, res) {
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
|
||||
const guestConfig = getGuestConfig();
|
||||
|
||||
// Strip server-internal fields from balance config before sending to client.
|
||||
// commerce.endpoint leaks K8s service URLs; commerce.token is a bearer secret.
|
||||
const clientBalanceConfig = balanceConfig
|
||||
@@ -124,8 +121,6 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
allowGuestChat: guestConfig.enabled,
|
||||
guestMessageMax: guestConfig.enabled ? guestConfig.messageMax : undefined,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
|
||||
@@ -15,7 +15,6 @@ const { forkConversation, duplicateConversation } = require('~/server/utils/impo
|
||||
const { storage, importFileFilter } = require('~/server/routes/files/multer');
|
||||
const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const { importConversations } = require('~/server/utils/import');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -27,16 +26,8 @@ const assistantClients = {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Guest-accessible conversation list. Guests have no persisted conversations, so
|
||||
* this returns an empty, well-formed page — enough for the UI to mount the chat
|
||||
* history pane without a DB lookup. Registered with guest-aware auth BEFORE the
|
||||
* strict JWT guard below; every mutation/read-by-id route stays JWT-only.
|
||||
*/
|
||||
router.get('/', requireGuestOrJwtAuth, async (req, res) => {
|
||||
if (req.user?.guest === true) {
|
||||
return res.status(200).json({ conversations: [], nextCursor: null });
|
||||
}
|
||||
/** Conversation list — JWT-only (no guest chat). */
|
||||
router.get('/', requireJwtAuth, async (req, res) => {
|
||||
const limit = parseInt(req.query.limit, 10) || 25;
|
||||
const cursor = req.query.cursor;
|
||||
const isArchived = isEnabled(req.query.isArchived);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const requireGuestOrJwtAuth = require('~/server/middleware/requireGuestOrJwtAuth');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, endpointController);
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const express = require('express');
|
||||
const { modelController } = require('~/server/controllers/ModelController');
|
||||
const { requireGuestOrJwtAuth } = require('~/server/middleware/');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
|
||||
const router = express.Router();
|
||||
router.get('/', requireGuestOrJwtAuth, modelController);
|
||||
router.get('/', requireJwtAuth, modelController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,12 +3,12 @@ const {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
} = require('~/server/controllers/FavoritesController');
|
||||
const { requireJwtAuth, requireGuestOrJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Read-only favorites are guest-safe (empty list); writes stay JWT-only.
|
||||
router.get('/favorites', requireGuestOrJwtAuth, getFavoritesController);
|
||||
// Favorites — JWT-only (no guest chat).
|
||||
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,7 +13,6 @@ const {
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
requireGuestOrJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const settings = require('./settings');
|
||||
@@ -21,7 +20,7 @@ const settings = require('./settings');
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/settings', settings);
|
||||
router.get('/', requireGuestOrJwtAuth, getUserController);
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
const { isEnabled } = require('@hanzochat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const GUEST_ROLE = 'GUEST';
|
||||
const GUEST_NAME = 'Guest';
|
||||
const DEFAULT_GUEST_MESSAGE_MAX = 3;
|
||||
const DEFAULT_GUEST_TOKEN_EXPIRY_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_GUEST_ENDPOINT = 'Hanzo';
|
||||
const DEFAULT_GUEST_MODEL = 'zen3-nano';
|
||||
|
||||
/**
|
||||
* Resolves the guest-chat configuration from the environment.
|
||||
* Guest chat is disabled unless `ALLOW_GUEST_CHAT` is explicitly enabled.
|
||||
*
|
||||
* @returns {{
|
||||
* enabled: boolean,
|
||||
* messageMax: number,
|
||||
* tokenExpiryMs: number,
|
||||
* endpoint: string,
|
||||
* model: string,
|
||||
* }}
|
||||
*/
|
||||
const getGuestConfig = () => {
|
||||
const messageMax = Number.parseInt(process.env.GUEST_MESSAGE_MAX, 10);
|
||||
const tokenExpiryMs = Number.parseInt(process.env.GUEST_TOKEN_EXPIRY, 10);
|
||||
|
||||
return {
|
||||
enabled: isEnabled(process.env.ALLOW_GUEST_CHAT),
|
||||
messageMax:
|
||||
Number.isFinite(messageMax) && messageMax > 0 ? messageMax : DEFAULT_GUEST_MESSAGE_MAX,
|
||||
tokenExpiryMs:
|
||||
Number.isFinite(tokenExpiryMs) && tokenExpiryMs > 0
|
||||
? tokenExpiryMs
|
||||
: DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
endpoint: process.env.GUEST_ENDPOINT || DEFAULT_GUEST_ENDPOINT,
|
||||
model: process.env.GUEST_MODEL || DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the ephemeral guest principal for a verified guest token.
|
||||
*
|
||||
* This is the SINGLE source of truth for the guest `req.user` shape. It is a
|
||||
* plain object — never a DB document — so no route ever reads or writes real
|
||||
* user data on behalf of a guest. No email, no DB id.
|
||||
*
|
||||
* @param {string} id - The synthetic guest id from the token (`guest_<uuid>`).
|
||||
* @returns {{ id: string, role: string, name: string, guest: true }}
|
||||
*/
|
||||
const buildGuestPrincipal = (id) => ({
|
||||
id,
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped `/v1/chat/user` response: the ephemeral principal only.
|
||||
* Mirrors the safe-field shape the client expects (no password/totp/email/db id).
|
||||
*
|
||||
* @param {{ id: string }} principal
|
||||
* @returns {object}
|
||||
*/
|
||||
const buildGuestUser = (principal) => ({
|
||||
id: principal.id,
|
||||
username: GUEST_NAME,
|
||||
name: GUEST_NAME,
|
||||
role: GUEST_ROLE,
|
||||
provider: 'guest',
|
||||
emailVerified: false,
|
||||
guest: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped endpoints config: ONLY the configured guest endpoint,
|
||||
* with no builder/agent/file/preset capabilities. Everything else is omitted so
|
||||
* the client cannot surface any other endpoint to a guest.
|
||||
*
|
||||
* @returns {Record<string, object>}
|
||||
*/
|
||||
const buildGuestEndpointsConfig = () => {
|
||||
const { endpoint } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: {
|
||||
type: EModelEndpoint.custom,
|
||||
userProvide: false,
|
||||
modelDisplayLabel: endpoint,
|
||||
order: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the guest-scoped models config: the single configured guest model under
|
||||
* the guest endpoint. The client pins the composer to exactly this one model.
|
||||
*
|
||||
* @returns {Record<string, string[]>}
|
||||
*/
|
||||
const buildGuestModelsConfig = () => {
|
||||
const { endpoint, model } = getGuestConfig();
|
||||
return {
|
||||
[endpoint]: [model],
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_TOKEN_EXPIRY_MS,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
jest.mock('@hanzochat/api', () => ({
|
||||
isEnabled: (value) => value === 'true' || value === true,
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
EModelEndpoint: { custom: 'custom' },
|
||||
}));
|
||||
|
||||
const {
|
||||
getGuestConfig,
|
||||
buildGuestPrincipal,
|
||||
buildGuestUser,
|
||||
buildGuestEndpointsConfig,
|
||||
buildGuestModelsConfig,
|
||||
GUEST_ROLE,
|
||||
GUEST_NAME,
|
||||
DEFAULT_GUEST_MESSAGE_MAX,
|
||||
DEFAULT_GUEST_ENDPOINT,
|
||||
DEFAULT_GUEST_MODEL,
|
||||
} = require('./guestConfig');
|
||||
|
||||
describe('getGuestConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ALLOW_GUEST_CHAT;
|
||||
delete process.env.GUEST_MESSAGE_MAX;
|
||||
delete process.env.GUEST_TOKEN_EXPIRY;
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('is disabled by default (fail closed)', () => {
|
||||
expect(getGuestConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled only when ALLOW_GUEST_CHAT is truthy', () => {
|
||||
process.env.ALLOW_GUEST_CHAT = 'true';
|
||||
expect(getGuestConfig().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses defaults when env is unset', () => {
|
||||
const config = getGuestConfig();
|
||||
expect(config.messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
expect(config.endpoint).toBe(DEFAULT_GUEST_ENDPOINT);
|
||||
expect(config.model).toBe(DEFAULT_GUEST_MODEL);
|
||||
});
|
||||
|
||||
it('honors GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = '7';
|
||||
expect(getGuestConfig().messageMax).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to default for invalid or non-positive GUEST_MESSAGE_MAX', () => {
|
||||
process.env.GUEST_MESSAGE_MAX = 'abc';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '0';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
process.env.GUEST_MESSAGE_MAX = '-5';
|
||||
expect(getGuestConfig().messageMax).toBe(DEFAULT_GUEST_MESSAGE_MAX);
|
||||
});
|
||||
|
||||
it('honors the configurable guest endpoint and model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen4-mini';
|
||||
const config = getGuestConfig();
|
||||
expect(config.endpoint).toBe('Hanzo');
|
||||
expect(config.model).toBe('zen4-mini');
|
||||
});
|
||||
|
||||
it('exposes GUEST_ROLE constant', () => {
|
||||
expect(GUEST_ROLE).toBe('GUEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest principal + scoped-config builders', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.GUEST_ENDPOINT;
|
||||
delete process.env.GUEST_MODEL;
|
||||
});
|
||||
|
||||
it('buildGuestPrincipal returns an ephemeral GUEST principal with no DB id/email', () => {
|
||||
const principal = buildGuestPrincipal('guest_abc');
|
||||
expect(principal).toEqual({
|
||||
id: 'guest_abc',
|
||||
role: GUEST_ROLE,
|
||||
name: GUEST_NAME,
|
||||
guest: true,
|
||||
});
|
||||
expect(principal).not.toHaveProperty('email');
|
||||
expect(principal).not.toHaveProperty('_id');
|
||||
});
|
||||
|
||||
it('buildGuestUser exposes only safe, guest-scoped fields (no email)', () => {
|
||||
const user = buildGuestUser(buildGuestPrincipal('guest_abc'));
|
||||
expect(user.id).toBe('guest_abc');
|
||||
expect(user.role).toBe(GUEST_ROLE);
|
||||
expect(user.name).toBe(GUEST_NAME);
|
||||
expect(user.guest).toBe(true);
|
||||
expect(user).not.toHaveProperty('email');
|
||||
expect(user).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('buildGuestEndpointsConfig exposes ONLY the configured guest endpoint', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
const config = buildGuestEndpointsConfig();
|
||||
expect(Object.keys(config)).toEqual(['Hanzo']);
|
||||
expect(config.Hanzo).toMatchObject({ type: 'custom', userProvide: false });
|
||||
});
|
||||
|
||||
it('buildGuestModelsConfig exposes ONLY the single configured guest model', () => {
|
||||
process.env.GUEST_ENDPOINT = 'Hanzo';
|
||||
process.env.GUEST_MODEL = 'zen5-mini';
|
||||
expect(buildGuestModelsConfig()).toEqual({ Hanzo: ['zen5-mini'] });
|
||||
});
|
||||
});
|
||||
@@ -71,9 +71,12 @@ const configureSocialLogins = async (app) => {
|
||||
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) {
|
||||
passport.use(appleLogin());
|
||||
}
|
||||
// PUBLIC PKCE client: registration MUST NOT be gated on a client secret. A
|
||||
// public client has none (security is PKCE + signed state), and gating on
|
||||
// OPENID_CLIENT_SECRET is exactly what left the `openid` strategy unregistered
|
||||
// ("OpenID strategy not registered") and login dead. See AUTH_BILLING_CONTRACT.md.
|
||||
if (
|
||||
process.env.OPENID_CLIENT_ID &&
|
||||
process.env.OPENID_CLIENT_SECRET &&
|
||||
process.env.OPENID_ISSUER &&
|
||||
process.env.OPENID_SCOPE &&
|
||||
process.env.OPENID_SESSION_SECRET
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
const removePorts = require('./removePorts');
|
||||
|
||||
/**
|
||||
* Resolves the real client IP for per-IP guest rate limiting.
|
||||
*
|
||||
* hanzo.chat is served behind Cloudflare → the DO LB → the ingress. With that
|
||||
* many hops, Express `req.ip` (via `trust proxy`) is not reliably the visitor's
|
||||
* address, which would let anonymous users share/reset their free-message bucket
|
||||
* (or collapse everyone into one bucket). Cloudflare always sets
|
||||
* `CF-Connecting-IP` to the true originating client and — unlike a
|
||||
* client-supplied `X-Forwarded-For` entry — a browser cannot forge it through
|
||||
* the CF edge. Prefer it; fall back to the trust-proxy-resolved `req.ip` when the
|
||||
* request did not transit Cloudflare (e.g. in-cluster/local).
|
||||
*
|
||||
* The returned string is the SOLE identity the guest quota keys on, so it must be
|
||||
* stable across guest tokens, cookie clears, and incognito sessions from the same
|
||||
* network origin.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string}
|
||||
*/
|
||||
const guestClientIp = (req) => {
|
||||
const cf = req.headers?.['cf-connecting-ip'];
|
||||
if (typeof cf === 'string' && cf.trim()) {
|
||||
return cf.trim();
|
||||
}
|
||||
return removePorts(req);
|
||||
};
|
||||
|
||||
module.exports = guestClientIp;
|
||||
@@ -1,5 +1,4 @@
|
||||
const removePorts = require('./removePorts');
|
||||
const guestClientIp = require('./guestClientIp');
|
||||
const handleText = require('./handleText');
|
||||
const sendEmail = require('./sendEmail');
|
||||
const queue = require('./queue');
|
||||
@@ -8,7 +7,6 @@ const files = require('./files');
|
||||
module.exports = {
|
||||
...handleText,
|
||||
removePorts,
|
||||
guestClientIp,
|
||||
sendEmail,
|
||||
...files,
|
||||
...queue,
|
||||
|
||||
@@ -13,11 +13,11 @@ const jwtLogin = () =>
|
||||
async (payload, done) => {
|
||||
try {
|
||||
/**
|
||||
* Guest tokens carry `guest: true` and a synthetic `guest_<uuid>` id that
|
||||
* is NOT a Mongo ObjectId. Reject them cleanly here so the strict `jwt`
|
||||
* strategy fails with a 401 instead of letting `getUserById` throw a
|
||||
* Mongoose CastError (→ 500). Guests reach their scoped routes through
|
||||
* `requireGuestOrJwtAuth`, never through this strategy. Fail closed.
|
||||
* Guest chat is removed (every request requires a signed-in IAM identity).
|
||||
* A legacy guest token left in a browser after deploy still carries
|
||||
* `guest: true` and a synthetic `guest_<uuid>` id that is NOT a Mongo
|
||||
* ObjectId — reject it cleanly here (401) rather than letting `getUserById`
|
||||
* throw a Mongoose CastError (→ 500). Fail closed.
|
||||
*/
|
||||
if (payload?.guest === true) {
|
||||
return done(null, false);
|
||||
|
||||
@@ -699,16 +699,30 @@ async function setupOpenId() {
|
||||
try {
|
||||
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
||||
|
||||
// PUBLIC PKCE client is the canonical (and only) mode: no client secret is
|
||||
// sent, and the token endpoint auth method is `none`. Security is PKCE
|
||||
// (code_challenge S256, OPENID_USE_PKCE=true) + the signed state, not a
|
||||
// shared secret shipped with a browser-delivered app. A confidential secret
|
||||
// is honored ONLY if one is still explicitly configured (legacy), never
|
||||
// required — its absence must never block strategy registration.
|
||||
const clientSecret = process.env.OPENID_CLIENT_SECRET;
|
||||
|
||||
/** @type {ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
};
|
||||
if (clientSecret) {
|
||||
clientMetadata.client_secret = clientSecret;
|
||||
} else {
|
||||
clientMetadata.token_endpoint_auth_method = 'none';
|
||||
}
|
||||
|
||||
if (shouldGenerateNonce) {
|
||||
clientMetadata.response_types = ['code'];
|
||||
clientMetadata.grant_types = ['authorization_code'];
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
if (clientSecret) {
|
||||
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Configuration} */
|
||||
@@ -735,7 +749,10 @@ async function setupOpenId() {
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
|
||||
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
||||
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
||||
// A public client (no secret) REQUIRES PKCE — force it on regardless of
|
||||
// the env toggle so a secretless deploy can never fall back to a bare,
|
||||
// uncertified code exchange.
|
||||
usePKCE: isEnabled(process.env.OPENID_USE_PKCE) || !clientSecret,
|
||||
},
|
||||
createOpenIDCallback(),
|
||||
);
|
||||
|
||||
+7
-7
@@ -88,7 +88,7 @@ endpoints:
|
||||
# completion path that carries the per-user hk- key) instead of bare
|
||||
# `agents`, which requires an explicit agent_id and 400s on first send.
|
||||
customOrder: 0
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
# zen* now route to DigitalOcean GenAI (do-ai) and return content
|
||||
# (cloud >= 1.785.11). fetch:false keeps the menu curated to the
|
||||
@@ -131,7 +131,7 @@ endpoints:
|
||||
# CM. Zen ("Hanzo") stays house brand + default at order 0.
|
||||
- name: "Qwen"
|
||||
customOrder: 1
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -145,7 +145,7 @@ endpoints:
|
||||
modelDisplayLabel: "Qwen"
|
||||
- name: "Meta Llama"
|
||||
customOrder: 2
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -158,7 +158,7 @@ endpoints:
|
||||
modelDisplayLabel: "Meta Llama"
|
||||
- name: "DeepSeek"
|
||||
customOrder: 3
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -172,7 +172,7 @@ endpoints:
|
||||
modelDisplayLabel: "DeepSeek"
|
||||
- name: "Mistral"
|
||||
customOrder: 4
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
iconURL: "/assets/mistral.png"
|
||||
models:
|
||||
@@ -186,7 +186,7 @@ endpoints:
|
||||
modelDisplayLabel: "Mistral"
|
||||
- name: "Google Gemma"
|
||||
customOrder: 5
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
@@ -198,7 +198,7 @@ endpoints:
|
||||
modelDisplayLabel: "Google Gemma"
|
||||
- name: "OpenAI GPT-OSS"
|
||||
customOrder: 6
|
||||
apiKey: "${HANZO_API_KEY}"
|
||||
apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"
|
||||
baseURL: "https://api.hanzo.ai/v1"
|
||||
models:
|
||||
default:
|
||||
|
||||
Vendored
+101
-280
@@ -471,10 +471,12 @@ function createBearerAuthHeader(tokenInfo) {
|
||||
return `Bearer ${tokenInfo.accessToken}`;
|
||||
}
|
||||
function isOpenIDAvailable() {
|
||||
// PUBLIC PKCE client: availability requires only a client id + issuer. A public
|
||||
// client has no secret, so requiring one would report OIDC as unavailable and
|
||||
// suppress login on a correctly-configured secretless deploy.
|
||||
const openidClientId = process.env.OPENID_CLIENT_ID;
|
||||
const openidClientSecret = process.env.OPENID_CLIENT_SECRET;
|
||||
const openidIssuer = process.env.OPENID_ISSUER;
|
||||
return !!(openidClientId && openidClientSecret && openidIssuer);
|
||||
return !!(openidClientId && openidIssuer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38692,278 +38694,100 @@ function getBedrockModels() {
|
||||
return models;
|
||||
}
|
||||
|
||||
const KEY_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const IAM_TIMEOUT_MS = 5000;
|
||||
/** Per-user hk- key cache: cacheKey (user id|email) -> { key, expiresAt }. */
|
||||
const keyCache = new Map();
|
||||
function truthy(value) {
|
||||
return (value !== null && value !== void 0 ? value : '').toString().trim().toLowerCase() === 'true';
|
||||
}
|
||||
/** IAM base URL — prefer an in-cluster override, else the OIDC issuer. */
|
||||
function iamBaseUrl() {
|
||||
const base = process.env.IAM_INTERNAL_URL ||
|
||||
process.env.IAM_SERVER_URL ||
|
||||
process.env.OPENID_ISSUER ||
|
||||
'';
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
function iamClientId() {
|
||||
return process.env.IAM_CLIENT_ID || process.env.OPENID_CLIENT_ID || '';
|
||||
}
|
||||
function iamClientSecret() {
|
||||
return process.env.IAM_CLIENT_SECRET || process.env.OPENID_CLIENT_SECRET || '';
|
||||
}
|
||||
/**
|
||||
* Whether per-user hk- billing is enabled AND fully configured. When false,
|
||||
* `resolveHanzoCloudKey` returns null and the shared key is used (legacy).
|
||||
*/
|
||||
function isHanzoPerUserKeyEnabled() {
|
||||
return (truthy(process.env.HANZO_PER_USER_KEY) &&
|
||||
Boolean(iamBaseUrl()) &&
|
||||
Boolean(iamClientId()) &&
|
||||
Boolean(iamClientSecret()));
|
||||
}
|
||||
function basicAuthHeader() {
|
||||
const raw = `${iamClientId()}:${iamClientSecret()}`;
|
||||
return `Basic ${Buffer.from(raw).toString('base64')}`;
|
||||
}
|
||||
// ── Per-user billing identity + starter credit ───────────────────────────────
|
||||
/**
|
||||
* Orgs whose MEMBERS are billed per-user (default: the shared "hanzo" catch-all,
|
||||
* the home of every unaffiliated individual signup). MUST mirror the gateway's
|
||||
* PERSONAL_BILLING_ORGS / object.BillingSubject (hanzoai/ai) so chat and the
|
||||
* gateway derive the identical subject.
|
||||
*/
|
||||
function personalBillingOrgs() {
|
||||
const raw = (process.env.HANZO_PERSONAL_BILLING_ORGS ||
|
||||
process.env.HANZO_DEFAULT_ORG ||
|
||||
'hanzo')
|
||||
.split(',')
|
||||
.map((o) => o.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return new Set(raw.length ? raw : ['hanzo']);
|
||||
}
|
||||
/**
|
||||
* Canonical Commerce billing subject for an IAM (owner, name) identity — the
|
||||
* account the cloud gateway debits and reads. Personal-billing org → "owner/name"
|
||||
* (per-user), pooled org → "owner". Always lowercased so it nets against the
|
||||
* gateway's usage writes. Byte-identical to object.BillingSubject in hanzoai/ai.
|
||||
*/
|
||||
function billingSubject(owner, name) {
|
||||
const o = (owner !== null && owner !== void 0 ? owner : '').toString().trim().toLowerCase();
|
||||
if (!o) {
|
||||
return '';
|
||||
/** Parse a Cookie header into a flat map (no dependency; RFC 6265 name=value pairs). */
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) {
|
||||
return out;
|
||||
}
|
||||
if (personalBillingOrgs().has(o)) {
|
||||
const n = (name !== null && name !== void 0 ? name : '').toString().trim().toLowerCase();
|
||||
return n ? `${o}/${n}` : o;
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, idx).trim();
|
||||
if (key && out[key] === undefined) {
|
||||
out[key] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
}
|
||||
return o;
|
||||
return out;
|
||||
}
|
||||
function commerceBaseUrl() {
|
||||
return (process.env.COMMERCE_API_URL || process.env.COMMERCE_ENDPOINT || '').replace(/\/+$/, '');
|
||||
}
|
||||
function commerceToken() {
|
||||
return process.env.COMMERCE_TOKEN || process.env.COMMERCE_API_TOKEN || '';
|
||||
}
|
||||
/** Subjects whose starter credit this process has already ensured (commerce is also idempotent). */
|
||||
const starterEnsured = new Set();
|
||||
/**
|
||||
* Ensure the new-user $5 welcome credit exists on THIS user's billing subject,
|
||||
* exactly once. Idempotent two ways: an in-process set (avoids re-hitting
|
||||
* commerce every key refresh) and commerce's own tag-deduped, transaction-guarded
|
||||
* grant (`POST /v1/billing/grant-starter`) — so concurrent chats can never
|
||||
* double-grant (no bleed).
|
||||
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
|
||||
* mandatory — the function only ever REMOVES a token from consideration:
|
||||
* - Decodable JWT: hanzo.id (the only cloud IdP) always issues JWTs; an
|
||||
* opaque/garbage token cannot be principal-bound, so it is never forwarded.
|
||||
* - Principal binding (MANDATORY): the token must NAME the authenticated user
|
||||
* (`sub === user.openidId`). If the binding cannot be ASSERTED — no
|
||||
* `openidId`, no `sub`, or a mismatch — the token is NOT forwarded. This keeps
|
||||
* "forwarded principal == req.user" true by construction, closing the
|
||||
* credential-mixing confused deputy (a session/cookie carrying a different
|
||||
* user's token) with no fail-open when binding data is absent.
|
||||
* - Expiry: never forward a token past its own `exp`.
|
||||
*
|
||||
* Best-effort but awaited: the credit must land on the subject BEFORE we forward
|
||||
* the user's hk- key to the gateway, otherwise the gateway sees a $0 balance and
|
||||
* 402s the very first chat. A commerce hiccup must NOT break key resolution,
|
||||
* though — the gateway still enforces balance, and the next message retries.
|
||||
* Decode-only (no signature verification): cloud performs the authoritative
|
||||
* JWKS + claim validation over the SAME claims, so it runs as exactly this
|
||||
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
|
||||
* binding; an intact `sub` is rejected by cloud on signature.
|
||||
*/
|
||||
function ensureStarterCredit(subject, owner) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const base = commerceBaseUrl();
|
||||
if (!base || !subject || starterEnsured.has(subject)) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
|
||||
try {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hanzo-Org': owner,
|
||||
};
|
||||
const tok = commerceToken();
|
||||
if (tok) {
|
||||
headers['Authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
const resp = yield fetch(`${base}/v1/billing/grant-starter`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ user: subject, trigger: 'chat_first_use' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (resp.ok) {
|
||||
starterEnsured.add(subject); // ensured this process; commerce dedupes across pods
|
||||
}
|
||||
else {
|
||||
dataSchemas.logger.warn('[hanzoCloudKey] starter-credit grant non-OK (continuing)', {
|
||||
subject,
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
dataSchemas.logger.warn('[hanzoCloudKey] starter-credit grant failed (continuing; gateway enforces)', {
|
||||
subject,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
function isForwardableToken(token, openidId) {
|
||||
if (!token || !openidId) {
|
||||
return false;
|
||||
}
|
||||
const claims = jwt.decode(token);
|
||||
if (!claims || typeof claims !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const c = claims;
|
||||
if (c.sub !== openidId) {
|
||||
return false;
|
||||
}
|
||||
if (typeof c.exp === 'number' && c.exp * 1000 <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Single IAM HTTP primitive (confidential-client Basic auth, `/v1/iam/*` JSON
|
||||
* API). Throws on transport error / non-ok status so callers fail closed.
|
||||
* Resolve the caller's forwardable Hanzo IAM bearer, or null.
|
||||
*
|
||||
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
|
||||
* authoritative user loaded by requireJwtAuth), NOT any cookie. A local user
|
||||
* never carries a hanzo.id token, so a stale OpenID session in the browser can
|
||||
* never be forwarded under a local identity.
|
||||
*
|
||||
* Every candidate — session first, then the httpOnly cookie fallback; id_token
|
||||
* preferred, access_token second — must pass `isForwardableToken`. Returns null
|
||||
* for an honest 401, never a wrong-principal / expired / unbound token.
|
||||
*/
|
||||
function iamRequest(path_1, params_1) {
|
||||
return __awaiter(this, arguments, void 0, function* (path, params, method = 'GET') {
|
||||
const url = new URL(`${iamBaseUrl()}${path}`);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = yield fetch(url.toString(), Object.assign(Object.assign({ method, headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
} }, (method === 'POST' ? { body: '{}' } : {})), { signal: controller.signal }));
|
||||
if (!resp.ok) {
|
||||
throw new Error(`IAM ${method} ${path} returned ${resp.status}`);
|
||||
}
|
||||
return (yield resp.json());
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Resolve the authoritative IAM record (owner/name/accessKey) by org + email. */
|
||||
function getIamUserByOrgEmail(owner, email) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a, _b;
|
||||
const res = yield iamRequest('/v1/iam/get-user', {
|
||||
owner,
|
||||
email: email.toLowerCase(),
|
||||
});
|
||||
if (res.status !== 'ok' || !((_a = res.data) === null || _a === void 0 ? void 0 : _a.owner) || !((_b = res.data) === null || _b === void 0 ? void 0 : _b.name)) {
|
||||
return null;
|
||||
}
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
/** Mint (create) the per-user hk- key for an IAM sub ("owner/name"). */
|
||||
function mintUserKey(sub) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a;
|
||||
const res = yield iamRequest('/v1/iam/mint-user-keys', { id: sub }, 'POST');
|
||||
if (res.status !== 'ok' || !((_a = res.data) === null || _a === void 0 ? void 0 : _a.accessKey)) {
|
||||
return null;
|
||||
}
|
||||
return res.data.accessKey;
|
||||
function resolveTenantBearer(req) {
|
||||
var _a, _b;
|
||||
const user = req.user;
|
||||
if (!user || user.provider !== 'openid') {
|
||||
return null;
|
||||
}
|
||||
const session = (_a = req.session) === null || _a === void 0 ? void 0 : _a.openidTokens;
|
||||
const cookies = parseCookies((_b = req.headers) === null || _b === void 0 ? void 0 : _b.cookie);
|
||||
const idToken = (session === null || session === void 0 ? void 0 : session.idToken) || cookies.openid_id_token;
|
||||
if (isForwardableToken(idToken, user.openidId)) {
|
||||
return idToken;
|
||||
}
|
||||
const accessToken = (session === null || session === void 0 ? void 0 : session.accessToken) || cookies.openid_access_token;
|
||||
if (isForwardableToken(accessToken, user.openidId)) {
|
||||
return accessToken;
|
||||
}
|
||||
dataSchemas.logger.debug('[tenantBearer] no forwardable IAM bearer for principal', {
|
||||
openidId: user.openidId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Resolve the authenticated user's own hk- Cloud API key, minting one on first
|
||||
* use if the IAM record has none. Returns null when per-user billing is
|
||||
* disabled/unconfigured, the user is a guest / has no email, or IAM cannot be
|
||||
* reached (caller FAILS CLOSED on null for an authenticated user).
|
||||
* The endpoint-config sentinel that opts a custom endpoint into IAM-bearer
|
||||
* forwarding. An endpoint whose `apiKey` is this value bills the signed-in user's
|
||||
* own org via their forwarded IAM token (the canonical Hanzo Cloud path) instead
|
||||
* of any static key. This reuses LibreChat's existing OIDC-token placeholder name
|
||||
* so librechat.yaml stays self-documenting.
|
||||
*/
|
||||
function resolveHanzoCloudKey(user) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
var _a, _b, _c, _d;
|
||||
if (!isHanzoPerUserKeyEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (!user || user.guest) {
|
||||
return null;
|
||||
}
|
||||
const email = ((_a = user.email) !== null && _a !== void 0 ? _a : '').toString().toLowerCase();
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = user.id || email;
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.key;
|
||||
}
|
||||
const defaultOrg = process.env.HANZO_DEFAULT_ORG || 'hanzo';
|
||||
const owner = ((_b = user.organization) !== null && _b !== void 0 ? _b : '').toString().trim() || defaultOrg;
|
||||
try {
|
||||
let record = yield getIamUserByOrgEmail(owner, email);
|
||||
// A user whose stored org is stale/missing may live in the default org.
|
||||
if (!record && owner !== defaultOrg) {
|
||||
record = yield getIamUserByOrgEmail(defaultOrg, email);
|
||||
}
|
||||
if (!(record === null || record === void 0 ? void 0 : record.owner) || !(record === null || record === void 0 ? void 0 : record.name)) {
|
||||
dataSchemas.logger.warn('[hanzoCloudKey] No IAM user for billing identity', {
|
||||
owner,
|
||||
email,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// Single authoritative identity: stamp the REAL billing org (record.owner)
|
||||
// back onto req.user. The OIDC-stored `organization` can be the Casdoor
|
||||
// super-org "admin" for some users, which is NOT where their hk- key bills —
|
||||
// so the downstream Commerce balance gate must use this resolved owner, not
|
||||
// the login-time value. This keeps the key and the gate on ONE org.
|
||||
const subject = billingSubject(record.owner, record.name);
|
||||
try {
|
||||
user.organization = record.owner;
|
||||
// Stamp the canonical billing subject so the balance gate keys on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all).
|
||||
user.billingSubject = subject;
|
||||
}
|
||||
catch (_e) {
|
||||
/* req.user may be a frozen/lean doc — non-fatal; gate still has gateway as backstop */
|
||||
}
|
||||
// Ensure THIS user's one-time $5 welcome credit exists on THEIR subject
|
||||
// before we hand back the key — so the gateway's first balance check sees it
|
||||
// instead of 402-ing a brand-new account. Idempotent + best-effort.
|
||||
yield ensureStarterCredit(subject, record.owner);
|
||||
let key = ((_c = record.accessKey) !== null && _c !== void 0 ? _c : '').trim();
|
||||
if (!key) {
|
||||
// Mint on first chat — the key is theirs going forward.
|
||||
key = (_d = (yield mintUserKey(`${record.owner}/${record.name}`))) !== null && _d !== void 0 ? _d : '';
|
||||
}
|
||||
if (!key) {
|
||||
dataSchemas.logger.error('[hanzoCloudKey] Failed to resolve/mint hk- key', {
|
||||
sub: `${record.owner}/${record.name}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
keyCache.set(cacheKey, { key, expiresAt: Date.now() + KEY_TTL_MS });
|
||||
return key;
|
||||
}
|
||||
catch (err) {
|
||||
dataSchemas.logger.error('[hanzoCloudKey] IAM lookup failed (failing closed)', {
|
||||
owner,
|
||||
email,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Test-only: clear the in-process key cache. */
|
||||
function _clearHanzoKeyCache() {
|
||||
keyCache.clear();
|
||||
}
|
||||
const OPENID_BEARER_SENTINEL = '{{LIBRECHAT_OPENID_TOKEN}}';
|
||||
|
||||
/**
|
||||
* Wrap an OpenAI-client `fetch` so the Hanzo Cloud gateway's HTTP-200 error
|
||||
@@ -39083,22 +38907,20 @@ function initializeCustom(_a) {
|
||||
}
|
||||
let apiKey = userProvidesKey ? userValues === null || userValues === void 0 ? void 0 : userValues.apiKey : CUSTOM_API_KEY;
|
||||
const baseURL = userProvidesURL ? userValues === null || userValues === void 0 ? void 0 : userValues.baseURL : CUSTOM_BASE_URL;
|
||||
// Hanzo per-user billing: an authenticated (non-guest) user's chat must be
|
||||
// billed to THEIR OWN org via THEIR OWN hk- key — never the shared key. We
|
||||
// resolve (mint on first chat) their key from IAM and use it here. If it
|
||||
// cannot be resolved we FAIL CLOSED (throw) rather than silently fall back to
|
||||
// the shared key, so an IAM hiccup can never route an authed user's spend onto
|
||||
// the shared org. Guests (anonymous preview) keep the shared, capped key.
|
||||
const billingUser = req.user;
|
||||
const isAuthenticatedUser = Boolean(billingUser && !billingUser.guest && billingUser.email);
|
||||
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
|
||||
const perUserKey = yield resolveHanzoCloudKey(billingUser);
|
||||
if (perUserKey) {
|
||||
apiKey = perUserKey;
|
||||
}
|
||||
else {
|
||||
throw new Error('Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai');
|
||||
// Canonical Hanzo Cloud auth+billing: an endpoint that declares
|
||||
// `apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"` bills the signed-in user's OWN org by
|
||||
// forwarding THEIR IAM bearer to cloud (api.hanzo.ai) as the request
|
||||
// credential. cloud validates the JWT, pins the tenant org from the verified
|
||||
// `owner` claim, and meters the org's shared plan then PAYG — no shared key, no
|
||||
// per-user minted key (see AUTH_BILLING_CONTRACT.md in hanzoai/cloud). If no
|
||||
// forwardable bearer exists (signed out / expired) we FAIL CLOSED: the user
|
||||
// must sign in with Hanzo. There is no fallback credential to spend on.
|
||||
if (apiKey === OPENID_BEARER_SENTINEL) {
|
||||
const bearer = resolveTenantBearer(req);
|
||||
if (!bearer) {
|
||||
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
|
||||
}
|
||||
apiKey = bearer;
|
||||
}
|
||||
if (userProvidesKey && !apiKey) {
|
||||
throw new Error(JSON.stringify({
|
||||
@@ -47255,6 +47077,7 @@ exports.OAUTH_SESSION_COOKIE = OAUTH_SESSION_COOKIE;
|
||||
exports.OAUTH_SESSION_COOKIE_PATH = OAUTH_SESSION_COOKIE_PATH;
|
||||
exports.OAUTH_SESSION_MAX_AGE = OAUTH_SESSION_MAX_AGE;
|
||||
exports.OAuthReconnectionManager = OAuthReconnectionManager;
|
||||
exports.OPENID_BEARER_SENTINEL = OPENID_BEARER_SENTINEL;
|
||||
exports.OpenAIChatModelStreamHandler = OpenAIChatModelStreamHandler;
|
||||
exports.OpenAIMessageDeltaHandler = OpenAIMessageDeltaHandler;
|
||||
exports.OpenAIModelEndHandler = OpenAIModelEndHandler;
|
||||
@@ -47266,7 +47089,6 @@ exports.RedisEventTransport = RedisEventTransport;
|
||||
exports.RedisJobStore = RedisJobStore;
|
||||
exports.StepTypes = StepTypes;
|
||||
exports.Tokenizer = TokenizerSingleton;
|
||||
exports._clearHanzoKeyCache = _clearHanzoKeyCache;
|
||||
exports.agentAvatarSchema = agentAvatarSchema;
|
||||
exports.agentBaseResourceSchema = agentBaseResourceSchema;
|
||||
exports.agentBaseSchema = agentBaseSchema;
|
||||
@@ -47283,7 +47105,6 @@ exports.applyDefaultParams = applyDefaultParams$1;
|
||||
exports.azureAISearchSchema = azureAISearchSchema;
|
||||
exports.backfillRemoteAgentPermissions = backfillRemoteAgentPermissions;
|
||||
exports.batchDeleteKeys = batchDeleteKeys;
|
||||
exports.billingSubject = billingSubject;
|
||||
exports.buildAgentInstructions = buildAgentInstructions;
|
||||
exports.buildAggregatedResponse = buildAggregatedResponse;
|
||||
exports.buildImageToolContext = buildImageToolContext;
|
||||
@@ -47510,7 +47331,7 @@ exports.isChatCompletionValidationFailure = isChatCompletionValidationFailure;
|
||||
exports.isConcurrentLimitEnabled = isConcurrentLimitEnabled;
|
||||
exports.isEmailDomainAllowed = isEmailDomainAllowed;
|
||||
exports.isEnabled = isEnabled;
|
||||
exports.isHanzoPerUserKeyEnabled = isHanzoPerUserKeyEnabled;
|
||||
exports.isForwardableToken = isForwardableToken;
|
||||
exports.isKnownCustomProvider = isKnownCustomProvider;
|
||||
exports.isMCPDomainAllowed = isMCPDomainAllowed;
|
||||
exports.isMCPDomainNotAllowedError = isMCPDomainNotAllowedError;
|
||||
@@ -47588,11 +47409,11 @@ exports.refreshListAvatars = refreshListAvatars;
|
||||
exports.requireAdmin = requireAdmin;
|
||||
exports.resolveGraphTokenPlaceholder = resolveGraphTokenPlaceholder;
|
||||
exports.resolveGraphTokensInRecord = resolveGraphTokensInRecord;
|
||||
exports.resolveHanzoCloudKey = resolveHanzoCloudKey;
|
||||
exports.resolveHeaders = resolveHeaders;
|
||||
exports.resolveHostnameSSRF = resolveHostnameSSRF;
|
||||
exports.resolveJsonSchemaRefs = resolveJsonSchemaRefs;
|
||||
exports.resolveNestedObject = resolveNestedObject;
|
||||
exports.resolveTenantBearer = resolveTenantBearer;
|
||||
exports.safeStringify = safeStringify;
|
||||
exports.safeValidatePromptGroupUpdate = safeValidatePromptGroupUpdate;
|
||||
exports.sanitizeFileForTransmit = sanitizeFileForTransmit;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,350 +0,0 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Per-user Hanzo Cloud (hk-) key resolution — the core of hanzo.chat per-user
|
||||
* billing.
|
||||
*
|
||||
* Each authenticated chat request must be billed to the LOGGED-IN USER'S OWN
|
||||
* Hanzo Cloud account, not a single shared key. Every IAM user has exactly one
|
||||
* `hk-…` Cloud API key on their IAM record (`User.AccessKey`); the cloud gateway
|
||||
* (api.hanzo.ai) debits that key's org commerce balance and returns 402 when the
|
||||
* org runs out. So forwarding the right per-user key === correct per-user billing
|
||||
* automatically.
|
||||
*
|
||||
* This module resolves (and, on first use, mints) that key from IAM by the
|
||||
* authenticated user's `organization` (IAM owner) + email, then caches it. It is
|
||||
* the SINGLE source of truth for "which hk- key bills this request"; the shared
|
||||
* `${HANZO_API_KEY}` is only the (capped, non-exempt) fallback for the
|
||||
* unauthenticated guest path.
|
||||
*
|
||||
* Identity: IAM resolves a user by `owner=<org>&email=<email>` (a Casdoor user's
|
||||
* `name` is not necessarily their email, and an email-only / UUID lookup does not
|
||||
* resolve), mirroring the console's `iamGetUserByOrgEmail`. Confidential-client
|
||||
* Basic auth (the chat OIDC client id + secret) authorizes the lookup/mint.
|
||||
*
|
||||
* FAIL CLOSED: callers MUST treat a `null` return for an authenticated user as
|
||||
* "cannot bill this user" and block — never fall back to the shared key, or an
|
||||
* IAM hiccup would silently route an authed user's spend onto the shared org.
|
||||
*/
|
||||
|
||||
type IamUserRecord = {
|
||||
owner?: string;
|
||||
name?: string;
|
||||
accessKey?: string;
|
||||
};
|
||||
|
||||
/** Minimal shape of the authenticated request user we need to bill. */
|
||||
export type HanzoBillingUser = {
|
||||
id?: string;
|
||||
email?: string | null;
|
||||
organization?: string | null;
|
||||
provider?: string;
|
||||
/** Guest (anonymous preview) users carry this and must NOT get a per-user key. */
|
||||
guest?: boolean;
|
||||
/**
|
||||
* Canonical Commerce billing subject (object.BillingSubject), stamped by
|
||||
* resolveHanzoCloudKey from the authoritative IAM record. The balance gate
|
||||
* reads this so it keys on the SAME account the gateway debits.
|
||||
*/
|
||||
billingSubject?: string | null;
|
||||
};
|
||||
|
||||
const KEY_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const IAM_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Per-user hk- key cache: cacheKey (user id|email) -> { key, expiresAt }. */
|
||||
const keyCache = new Map<string, { key: string; expiresAt: number }>();
|
||||
|
||||
function truthy(value?: string): boolean {
|
||||
return (value ?? '').toString().trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/** IAM base URL — prefer an in-cluster override, else the OIDC issuer. */
|
||||
function iamBaseUrl(): string {
|
||||
const base =
|
||||
process.env.IAM_INTERNAL_URL ||
|
||||
process.env.IAM_SERVER_URL ||
|
||||
process.env.OPENID_ISSUER ||
|
||||
'';
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function iamClientId(): string {
|
||||
return process.env.IAM_CLIENT_ID || process.env.OPENID_CLIENT_ID || '';
|
||||
}
|
||||
|
||||
function iamClientSecret(): string {
|
||||
return process.env.IAM_CLIENT_SECRET || process.env.OPENID_CLIENT_SECRET || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether per-user hk- billing is enabled AND fully configured. When false,
|
||||
* `resolveHanzoCloudKey` returns null and the shared key is used (legacy).
|
||||
*/
|
||||
export function isHanzoPerUserKeyEnabled(): boolean {
|
||||
return (
|
||||
truthy(process.env.HANZO_PER_USER_KEY) &&
|
||||
Boolean(iamBaseUrl()) &&
|
||||
Boolean(iamClientId()) &&
|
||||
Boolean(iamClientSecret())
|
||||
);
|
||||
}
|
||||
|
||||
function basicAuthHeader(): string {
|
||||
const raw = `${iamClientId()}:${iamClientSecret()}`;
|
||||
return `Basic ${Buffer.from(raw).toString('base64')}`;
|
||||
}
|
||||
|
||||
// ── Per-user billing identity + starter credit ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Orgs whose MEMBERS are billed per-user (default: the shared "hanzo" catch-all,
|
||||
* the home of every unaffiliated individual signup). MUST mirror the gateway's
|
||||
* PERSONAL_BILLING_ORGS / object.BillingSubject (hanzoai/ai) so chat and the
|
||||
* gateway derive the identical subject.
|
||||
*/
|
||||
function personalBillingOrgs(): Set<string> {
|
||||
const raw = (
|
||||
process.env.HANZO_PERSONAL_BILLING_ORGS ||
|
||||
process.env.HANZO_DEFAULT_ORG ||
|
||||
'hanzo'
|
||||
)
|
||||
.split(',')
|
||||
.map((o) => o.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return new Set(raw.length ? raw : ['hanzo']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical Commerce billing subject for an IAM (owner, name) identity — the
|
||||
* account the cloud gateway debits and reads. Personal-billing org → "owner/name"
|
||||
* (per-user), pooled org → "owner". Always lowercased so it nets against the
|
||||
* gateway's usage writes. Byte-identical to object.BillingSubject in hanzoai/ai.
|
||||
*/
|
||||
export function billingSubject(owner?: string | null, name?: string | null): string {
|
||||
const o = (owner ?? '').toString().trim().toLowerCase();
|
||||
if (!o) {
|
||||
return '';
|
||||
}
|
||||
if (personalBillingOrgs().has(o)) {
|
||||
const n = (name ?? '').toString().trim().toLowerCase();
|
||||
return n ? `${o}/${n}` : o;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function commerceBaseUrl(): string {
|
||||
return (process.env.COMMERCE_API_URL || process.env.COMMERCE_ENDPOINT || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function commerceToken(): string {
|
||||
return process.env.COMMERCE_TOKEN || process.env.COMMERCE_API_TOKEN || '';
|
||||
}
|
||||
|
||||
/** Subjects whose starter credit this process has already ensured (commerce is also idempotent). */
|
||||
const starterEnsured = new Set<string>();
|
||||
|
||||
/**
|
||||
* Ensure the new-user $5 welcome credit exists on THIS user's billing subject,
|
||||
* exactly once. Idempotent two ways: an in-process set (avoids re-hitting
|
||||
* commerce every key refresh) and commerce's own tag-deduped, transaction-guarded
|
||||
* grant (`POST /v1/billing/grant-starter`) — so concurrent chats can never
|
||||
* double-grant (no bleed).
|
||||
*
|
||||
* Best-effort but awaited: the credit must land on the subject BEFORE we forward
|
||||
* the user's hk- key to the gateway, otherwise the gateway sees a $0 balance and
|
||||
* 402s the very first chat. A commerce hiccup must NOT break key resolution,
|
||||
* though — the gateway still enforces balance, and the next message retries.
|
||||
*/
|
||||
async function ensureStarterCredit(subject: string, owner: string): Promise<void> {
|
||||
const base = commerceBaseUrl();
|
||||
if (!base || !subject || starterEnsured.has(subject)) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hanzo-Org': owner,
|
||||
};
|
||||
const tok = commerceToken();
|
||||
if (tok) {
|
||||
headers['Authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
const resp = await fetch(`${base}/v1/billing/grant-starter`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ user: subject, trigger: 'chat_first_use' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (resp.ok) {
|
||||
starterEnsured.add(subject); // ensured this process; commerce dedupes across pods
|
||||
} else {
|
||||
logger.warn('[hanzoCloudKey] starter-credit grant non-OK (continuing)', {
|
||||
subject,
|
||||
status: resp.status,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[hanzoCloudKey] starter-credit grant failed (continuing; gateway enforces)', {
|
||||
subject,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single IAM HTTP primitive (confidential-client Basic auth, `/v1/iam/*` JSON
|
||||
* API). Throws on transport error / non-ok status so callers fail closed.
|
||||
*/
|
||||
async function iamRequest(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
): Promise<{ status?: string; data?: IamUserRecord & { accessKey?: string } }> {
|
||||
const url = new URL(`${iamBaseUrl()}${path}`);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...(method === 'POST' ? { body: '{}' } : {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`IAM ${method} ${path} returned ${resp.status}`);
|
||||
}
|
||||
return (await resp.json()) as { status?: string; data?: IamUserRecord };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the authoritative IAM record (owner/name/accessKey) by org + email. */
|
||||
async function getIamUserByOrgEmail(
|
||||
owner: string,
|
||||
email: string,
|
||||
): Promise<IamUserRecord | null> {
|
||||
const res = await iamRequest('/v1/iam/get-user', {
|
||||
owner,
|
||||
email: email.toLowerCase(),
|
||||
});
|
||||
if (res.status !== 'ok' || !res.data?.owner || !res.data?.name) {
|
||||
return null;
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Mint (create) the per-user hk- key for an IAM sub ("owner/name"). */
|
||||
async function mintUserKey(sub: string): Promise<string | null> {
|
||||
const res = await iamRequest('/v1/iam/mint-user-keys', { id: sub }, 'POST');
|
||||
if (res.status !== 'ok' || !res.data?.accessKey) {
|
||||
return null;
|
||||
}
|
||||
return res.data.accessKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user's own hk- Cloud API key, minting one on first
|
||||
* use if the IAM record has none. Returns null when per-user billing is
|
||||
* disabled/unconfigured, the user is a guest / has no email, or IAM cannot be
|
||||
* reached (caller FAILS CLOSED on null for an authenticated user).
|
||||
*/
|
||||
export async function resolveHanzoCloudKey(
|
||||
user?: HanzoBillingUser | null,
|
||||
): Promise<string | null> {
|
||||
if (!isHanzoPerUserKeyEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (!user || user.guest) {
|
||||
return null;
|
||||
}
|
||||
const email = (user.email ?? '').toString().toLowerCase();
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = user.id || email;
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.key;
|
||||
}
|
||||
|
||||
const defaultOrg = process.env.HANZO_DEFAULT_ORG || 'hanzo';
|
||||
const owner = (user.organization ?? '').toString().trim() || defaultOrg;
|
||||
|
||||
try {
|
||||
let record = await getIamUserByOrgEmail(owner, email);
|
||||
// A user whose stored org is stale/missing may live in the default org.
|
||||
if (!record && owner !== defaultOrg) {
|
||||
record = await getIamUserByOrgEmail(defaultOrg, email);
|
||||
}
|
||||
if (!record?.owner || !record?.name) {
|
||||
logger.warn('[hanzoCloudKey] No IAM user for billing identity', {
|
||||
owner,
|
||||
email,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single authoritative identity: stamp the REAL billing org (record.owner)
|
||||
// back onto req.user. The OIDC-stored `organization` can be the Casdoor
|
||||
// super-org "admin" for some users, which is NOT where their hk- key bills —
|
||||
// so the downstream Commerce balance gate must use this resolved owner, not
|
||||
// the login-time value. This keeps the key and the gate on ONE org.
|
||||
const subject = billingSubject(record.owner, record.name);
|
||||
try {
|
||||
user.organization = record.owner;
|
||||
// Stamp the canonical billing subject so the balance gate keys on the SAME
|
||||
// account the gateway debits (per-user for the shared "hanzo" catch-all).
|
||||
user.billingSubject = subject;
|
||||
} catch {
|
||||
/* req.user may be a frozen/lean doc — non-fatal; gate still has gateway as backstop */
|
||||
}
|
||||
|
||||
// Ensure THIS user's one-time $5 welcome credit exists on THEIR subject
|
||||
// before we hand back the key — so the gateway's first balance check sees it
|
||||
// instead of 402-ing a brand-new account. Idempotent + best-effort.
|
||||
await ensureStarterCredit(subject, record.owner);
|
||||
|
||||
let key = (record.accessKey ?? '').trim();
|
||||
if (!key) {
|
||||
// Mint on first chat — the key is theirs going forward.
|
||||
key = (await mintUserKey(`${record.owner}/${record.name}`)) ?? '';
|
||||
}
|
||||
if (!key) {
|
||||
logger.error('[hanzoCloudKey] Failed to resolve/mint hk- key', {
|
||||
sub: `${record.owner}/${record.name}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
keyCache.set(cacheKey, { key, expiresAt: Date.now() + KEY_TTL_MS });
|
||||
return key;
|
||||
} catch (err) {
|
||||
logger.error('[hanzoCloudKey] IAM lookup failed (failing closed)', {
|
||||
owner,
|
||||
email,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the in-process key cache. */
|
||||
export function _clearHanzoKeyCache(): void {
|
||||
keyCache.clear();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './hanzoCloudKey';
|
||||
export * from './tenantBearer';
|
||||
export * from './hanzoGatewayFetch';
|
||||
export * from './initialize';
|
||||
|
||||
@@ -13,11 +13,7 @@ import { getCustomEndpointConfig } from '~/app/config';
|
||||
import { fetchModels } from '~/endpoints/models';
|
||||
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
|
||||
import { standardCache } from '~/cache';
|
||||
import {
|
||||
isHanzoPerUserKeyEnabled,
|
||||
resolveHanzoCloudKey,
|
||||
type HanzoBillingUser,
|
||||
} from './hanzoCloudKey';
|
||||
import { resolveTenantBearer, OPENID_BEARER_SENTINEL } from './tenantBearer';
|
||||
import { wrapHanzoGatewayFetch, type GatewayFetch } from './hanzoGatewayFetch';
|
||||
|
||||
const { PROXY } = process.env;
|
||||
@@ -105,25 +101,20 @@ export async function initializeCustom({
|
||||
let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
|
||||
const baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
|
||||
|
||||
// Hanzo per-user billing: an authenticated (non-guest) user's chat must be
|
||||
// billed to THEIR OWN org via THEIR OWN hk- key — never the shared key. We
|
||||
// resolve (mint on first chat) their key from IAM and use it here. If it
|
||||
// cannot be resolved we FAIL CLOSED (throw) rather than silently fall back to
|
||||
// the shared key, so an IAM hiccup can never route an authed user's spend onto
|
||||
// the shared org. Guests (anonymous preview) keep the shared, capped key.
|
||||
const billingUser = req.user as unknown as HanzoBillingUser | undefined;
|
||||
const isAuthenticatedUser = Boolean(
|
||||
billingUser && !billingUser.guest && billingUser.email,
|
||||
);
|
||||
if (isHanzoPerUserKeyEnabled() && isAuthenticatedUser) {
|
||||
const perUserKey = await resolveHanzoCloudKey(billingUser);
|
||||
if (perUserKey) {
|
||||
apiKey = perUserKey;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Your Hanzo Cloud account is not linked for billing yet. Please sign out and back in, then claim your starter credit at https://billing.hanzo.ai',
|
||||
);
|
||||
// Canonical Hanzo Cloud auth+billing: an endpoint that declares
|
||||
// `apiKey: "{{LIBRECHAT_OPENID_TOKEN}}"` bills the signed-in user's OWN org by
|
||||
// forwarding THEIR IAM bearer to cloud (api.hanzo.ai) as the request
|
||||
// credential. cloud validates the JWT, pins the tenant org from the verified
|
||||
// `owner` claim, and meters the org's shared plan then PAYG — no shared key, no
|
||||
// per-user minted key (see AUTH_BILLING_CONTRACT.md in hanzoai/cloud). If no
|
||||
// forwardable bearer exists (signed out / expired) we FAIL CLOSED: the user
|
||||
// must sign in with Hanzo. There is no fallback credential to spend on.
|
||||
if (apiKey === OPENID_BEARER_SENTINEL) {
|
||||
const bearer = resolveTenantBearer(req as unknown as Parameters<typeof resolveTenantBearer>[0]);
|
||||
if (!bearer) {
|
||||
throw new Error('Sign in with Hanzo to chat — your Hanzo account funds this request.');
|
||||
}
|
||||
apiKey = bearer;
|
||||
}
|
||||
|
||||
if (userProvidesKey && !apiKey) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* The ONE way chat forwards a signed-in user's Hanzo IAM bearer to cloud
|
||||
* (api.hanzo.ai). Both the chat-completion path (custom/initialize.ts) and the
|
||||
* cloud-agents path (server/routes/agents/cloud.js) resolve the bearer through
|
||||
* THIS function — there is no second bearer-resolution anywhere.
|
||||
*
|
||||
* The bearer IS the credential AND the billing identity: cloud validates the JWT
|
||||
* (JWKS sig + issuer + audience + exp), pins the tenant org from the verified
|
||||
* `owner` claim, and meters the org's shared plan then PAYG. chat holds NO shared
|
||||
* key and mints NO per-user key — see AUTH_BILLING_CONTRACT.md in hanzoai/cloud.
|
||||
*
|
||||
* The token lives server-side (the OIDC session store, httpOnly cookie fallback)
|
||||
* and is NEVER returned to the browser.
|
||||
*/
|
||||
|
||||
/** The request shape this resolver needs — a subset of the Express request. */
|
||||
export interface TenantBearerRequest {
|
||||
user?: {
|
||||
provider?: string;
|
||||
openidId?: string;
|
||||
} | null;
|
||||
session?: {
|
||||
openidTokens?: {
|
||||
idToken?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
};
|
||||
headers?: {
|
||||
cookie?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a Cookie header into a flat map (no dependency; RFC 6265 name=value pairs). */
|
||||
function parseCookies(header?: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) {
|
||||
return out;
|
||||
}
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, idx).trim();
|
||||
if (key && out[key] === undefined) {
|
||||
out[key] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this token safe to forward on-behalf-of `user`? Fail-secure gates, ALL
|
||||
* mandatory — the function only ever REMOVES a token from consideration:
|
||||
* - Decodable JWT: hanzo.id (the only cloud IdP) always issues JWTs; an
|
||||
* opaque/garbage token cannot be principal-bound, so it is never forwarded.
|
||||
* - Principal binding (MANDATORY): the token must NAME the authenticated user
|
||||
* (`sub === user.openidId`). If the binding cannot be ASSERTED — no
|
||||
* `openidId`, no `sub`, or a mismatch — the token is NOT forwarded. This keeps
|
||||
* "forwarded principal == req.user" true by construction, closing the
|
||||
* credential-mixing confused deputy (a session/cookie carrying a different
|
||||
* user's token) with no fail-open when binding data is absent.
|
||||
* - Expiry: never forward a token past its own `exp`.
|
||||
*
|
||||
* Decode-only (no signature verification): cloud performs the authoritative
|
||||
* JWKS + claim validation over the SAME claims, so it runs as exactly this
|
||||
* `sub`. A forged/tampered token gains nothing here — changing `sub` fails the
|
||||
* binding; an intact `sub` is rejected by cloud on signature.
|
||||
*/
|
||||
export function isForwardableToken(token: string | undefined, openidId?: string): boolean {
|
||||
if (!token || !openidId) {
|
||||
return false;
|
||||
}
|
||||
const claims = jwt.decode(token);
|
||||
if (!claims || typeof claims !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const c = claims as { sub?: string; exp?: number };
|
||||
if (c.sub !== openidId) {
|
||||
return false;
|
||||
}
|
||||
if (typeof c.exp === 'number' && c.exp * 1000 <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's forwardable Hanzo IAM bearer, or null.
|
||||
*
|
||||
* Keyed off the VALIDATED principal (`req.user.provider === 'openid'` — the
|
||||
* authoritative user loaded by requireJwtAuth), NOT any cookie. A local user
|
||||
* never carries a hanzo.id token, so a stale OpenID session in the browser can
|
||||
* never be forwarded under a local identity.
|
||||
*
|
||||
* Every candidate — session first, then the httpOnly cookie fallback; id_token
|
||||
* preferred, access_token second — must pass `isForwardableToken`. Returns null
|
||||
* for an honest 401, never a wrong-principal / expired / unbound token.
|
||||
*/
|
||||
export function resolveTenantBearer(req: TenantBearerRequest): string | null {
|
||||
const user = req.user;
|
||||
if (!user || user.provider !== 'openid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = req.session?.openidTokens;
|
||||
const cookies = parseCookies(req.headers?.cookie);
|
||||
|
||||
const idToken = session?.idToken || cookies.openid_id_token;
|
||||
if (isForwardableToken(idToken, user.openidId)) {
|
||||
return idToken as string;
|
||||
}
|
||||
|
||||
const accessToken = session?.accessToken || cookies.openid_access_token;
|
||||
if (isForwardableToken(accessToken, user.openidId)) {
|
||||
return accessToken as string;
|
||||
}
|
||||
|
||||
logger.debug('[tenantBearer] no forwardable IAM bearer for principal', {
|
||||
openidId: user.openidId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint-config sentinel that opts a custom endpoint into IAM-bearer
|
||||
* forwarding. An endpoint whose `apiKey` is this value bills the signed-in user's
|
||||
* own org via their forwarded IAM token (the canonical Hanzo Cloud path) instead
|
||||
* of any static key. This reuses LibreChat's existing OIDC-token placeholder name
|
||||
* so librechat.yaml stays self-documenting.
|
||||
*/
|
||||
export const OPENID_BEARER_SENTINEL = '{{LIBRECHAT_OPENID_TOKEN}}';
|
||||
@@ -183,9 +183,11 @@ export function createBearerAuthHeader(tokenInfo: OpenIDTokenInfo | null): strin
|
||||
}
|
||||
|
||||
export function isOpenIDAvailable(): boolean {
|
||||
// PUBLIC PKCE client: availability requires only a client id + issuer. A public
|
||||
// client has no secret, so requiring one would report OIDC as unavailable and
|
||||
// suppress login on a correctly-configured secretless deploy.
|
||||
const openidClientId = process.env.OPENID_CLIENT_ID;
|
||||
const openidClientSecret = process.env.OPENID_CLIENT_SECRET;
|
||||
const openidIssuer = process.env.OPENID_ISSUER;
|
||||
|
||||
return !!(openidClientId && openidClientSecret && openidIssuer);
|
||||
return !!(openidClientId && openidIssuer);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user