chore: import upstream snapshot with attribution
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:12 +08:00
commit e115934061
3584 changed files with 848449 additions and 0 deletions
@@ -0,0 +1,30 @@
const { logger } = require('@librechat/data-schemas');
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const loginController = async (req, res) => {
try {
if (!req.user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
if (req.user.twoFactorEnabled) {
const tempToken = generate2FATempToken(req.user._id);
return res.status(200).json({ twoFAPending: true, tempToken });
}
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
user.id = user._id.toString();
const token = await setAuthTokens(req.user._id, res, null, req);
return res.status(200).send({ token, user });
} catch (err) {
logger.error('[loginController]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};
module.exports = {
loginController,
};
@@ -0,0 +1,144 @@
const cookies = require('cookie');
const { isEnabled, clearCloudFrontCookies } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logoutUser } = require('~/server/services/AuthService');
const { getOpenIdConfig } = require('~/strategies');
/** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */
function parseMaxLogoutUrlLength(defaultValue = 2000) {
const raw = process.env.OPENID_MAX_LOGOUT_URL_LENGTH;
const trimmed = raw == null ? '' : raw.trim();
if (trimmed === '') {
return defaultValue;
}
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) {
logger.warn(
`[logoutController] Invalid OPENID_MAX_LOGOUT_URL_LENGTH value "${raw}", using default ${defaultValue}`,
);
return defaultValue;
}
return parsed;
}
const logoutController = async (req, res) => {
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
let refreshToken;
let idToken;
if (isOpenIdUser && req.session?.openidTokens) {
refreshToken = req.session.openidTokens.refreshToken;
idToken = req.session.openidTokens.idToken;
delete req.session.openidTokens;
}
refreshToken = refreshToken || parsedCookies.refreshToken;
idToken = idToken || parsedCookies.openid_id_token;
try {
const logout = await logoutUser(req, refreshToken);
const { status, message } = logout;
res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');
clearCloudFrontCookies(res, {
userId: req.user?.id ?? req.user?._id?.toString?.(),
tenantId: req.user?.tenantId,
});
const response = { message };
if (
isOpenIdUser &&
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
process.env.OPENID_ISSUER
) {
let openIdConfig;
try {
openIdConfig = getOpenIdConfig();
} catch (err) {
logger.warn('[logoutController] OpenID config not available:', err.message);
}
if (openIdConfig) {
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
if (endSessionEndpoint) {
const endSessionUrl = new URL(endSessionEndpoint);
const postLogoutRedirectUri =
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
/**
* OIDC RP-Initiated Logout cascading strategy:
* 1. id_token_hint (most secure, identifies exact session)
* 2. logout_hint + client_id (when URL would exceed safe length)
* 3. client_id only (when no token available)
*
* JWT tokens from spec-compliant OIDC providers use base64url
* encoding (RFC 7515), whose characters are all URL-safe, so
* token length equals URL-encoded length for projection.
* Non-compliant issuers using standard base64 (+/=) will cause
* underestimation; increase OPENID_MAX_LOGOUT_URL_LENGTH if the
* fallback does not trigger as expected.
*/
const maxLogoutUrlLength = parseMaxLogoutUrlLength();
let strategy = 'no_token';
if (idToken) {
const baseLength = endSessionUrl.toString().length;
const projectedLength = baseLength + '&id_token_hint='.length + idToken.length;
if (projectedLength > maxLogoutUrlLength) {
strategy = 'too_long';
logger.debug(
`[logoutController] Logout URL too long (${projectedLength} chars, max ${maxLogoutUrlLength}), ` +
'switching to logout_hint strategy',
);
} else {
strategy = 'use_token';
}
}
if (strategy === 'use_token') {
endSessionUrl.searchParams.set('id_token_hint', idToken);
} else {
if (strategy === 'too_long') {
const logoutHint = req.user?.email || req.user?.username || req.user?.openidId;
if (logoutHint) {
endSessionUrl.searchParams.set('logout_hint', logoutHint);
}
}
if (process.env.OPENID_CLIENT_ID) {
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
} else if (strategy === 'too_long') {
logger.warn(
'[logoutController] Logout URL exceeds max length and OPENID_CLIENT_ID is not set. ' +
'The OIDC end-session request may be rejected. ' +
'Consider setting OPENID_CLIENT_ID or increasing OPENID_MAX_LOGOUT_URL_LENGTH.',
);
} else {
logger.warn(
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
'The OIDC end-session request may be rejected by the identity provider.',
);
}
}
response.redirect = endSessionUrl.toString();
} else {
logger.warn(
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
);
}
}
}
return res.status(status).send(response);
} catch (err) {
logger.error('[logoutController]', err);
return res.status(500).json({ message: err.message });
}
};
module.exports = {
logoutController,
};
@@ -0,0 +1,583 @@
const cookies = require('cookie');
const mockLogoutUser = jest.fn();
const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
const mockIsEnabled = jest.fn();
const mockGetOpenIdConfig = jest.fn();
const mockClearCloudFrontCookies = jest.fn();
jest.mock('cookie');
jest.mock('@librechat/api', () => ({
isEnabled: (...args) => mockIsEnabled(...args),
clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
}));
jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
jest.mock('~/server/services/AuthService', () => ({
logoutUser: (...args) => mockLogoutUser(...args),
}));
jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() }));
const { logoutController } = require('./LogoutController');
function buildReq(overrides = {}) {
return {
user: { _id: 'user1', openidId: 'oid1', provider: 'openid' },
headers: { cookie: 'refreshToken=rt1' },
session: {
openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' },
destroy: jest.fn(),
},
...overrides,
};
}
function buildRes() {
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
clearCookie: jest.fn(),
};
return res;
}
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...ORIGINAL_ENV,
OPENID_USE_END_SESSION_ENDPOINT: 'true',
OPENID_ISSUER: 'https://idp.example.com',
OPENID_CLIENT_ID: 'my-client-id',
DOMAIN_CLIENT: 'https://app.example.com',
};
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' });
mockIsEnabled.mockReturnValue(true);
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({
end_session_endpoint: 'https://idp.example.com/logout',
}),
});
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
describe('LogoutController', () => {
describe('id_token_hint from session', () => {
it('sets id_token_hint when session has idToken', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
expect(body.redirect).not.toContain('client_id=');
});
});
describe('id_token_hint from cookie fallback', () => {
it('uses cookie id_token when session has no tokens', async () => {
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: 'cookie-id-token',
});
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=cookie-id-token');
});
});
describe('client_id fallback', () => {
it('falls back to client_id when no idToken is available', async () => {
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('client_id=my-client-id');
expect(body.redirect).not.toContain('id_token_hint=');
});
it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => {
delete process.env.OPENID_CLIENT_ID;
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
const req = buildReq({ session: { destroy: jest.fn() } });
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('client_id=');
expect(body.redirect).not.toContain('undefined');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'),
);
});
});
describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => {
it('does not include redirect when disabled', async () => {
mockIsEnabled.mockReturnValue(false);
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('OPENID_ISSUER unset', () => {
it('does not include redirect when OPENID_ISSUER is missing', async () => {
delete process.env.OPENID_ISSUER;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('non-OpenID user', () => {
it('does not include redirect for non-OpenID users', async () => {
const req = buildReq({
user: { _id: 'user1', provider: 'local' },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
});
});
describe('post_logout_redirect_uri', () => {
it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => {
process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://custom.example.com/logged-out',
);
});
it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => {
delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI;
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
const url = new URL(body.redirect);
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://app.example.com/login',
);
});
});
describe('OpenID config not available', () => {
it('warns and returns no redirect when getOpenIdConfig throws', async () => {
mockGetOpenIdConfig.mockImplementation(() => {
throw new Error('OpenID configuration has not been initialized');
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OpenID config not available'),
'OpenID configuration has not been initialized',
);
});
});
describe('end_session_endpoint not in metadata', () => {
it('warns and returns no redirect when end_session_endpoint is missing', async () => {
mockGetOpenIdConfig.mockReturnValue({
serverMetadata: () => ({}),
});
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toBeUndefined();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('end_session_endpoint not found'),
);
});
});
describe('error handling', () => {
it('returns 500 on logoutUser error', async () => {
mockLogoutUser.mockRejectedValue(new Error('session error'));
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ message: 'session error' });
});
});
describe('cookie clearing', () => {
it('clears all auth cookies on successful logout', async () => {
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token');
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
});
it('calls clearCloudFrontCookies on successful logout', async () => {
const req = buildReq({ user: { _id: 'user1', tenantId: 'tenantA' } });
const res = buildRes();
await logoutController(req, res);
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res, {
userId: 'user1',
tenantId: 'tenantA',
});
});
});
describe('URL length limit and logout_hint fallback', () => {
it('uses logout_hint when id_token makes URL exceed default limit (2000 chars)', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Logout URL too long'));
});
it('uses id_token_hint when URL is within default limit', async () => {
const shortIdToken = 'short-token';
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: shortIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=short-token');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
});
it('respects custom OPENID_MAX_LOGOUT_URL_LENGTH', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500';
const mediumIdToken = 'a'.repeat(600);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: mediumIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
});
it('uses username as logout_hint when email is not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid1',
provider: 'openid',
username: 'testuser',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=testuser');
});
it('uses openidId as logout_hint when email and username are not available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'unique-oid-123', provider: 'openid' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('logout_hint=unique-oid-123');
});
it('uses openidId as logout_hint when email and username are explicitly null', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: 'oid-without-email',
provider: 'openid',
email: null,
username: null,
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=oid-without-email');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('uses only client_id when absolutely no hint is available', async () => {
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: {
_id: 'user1',
openidId: '',
provider: 'openid',
email: '',
username: '',
},
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('warns about missing OPENID_CLIENT_ID when URL is too long', async () => {
delete process.env.OPENID_CLIENT_ID;
const longIdToken = 'a'.repeat(3000);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
expect(body.redirect).not.toContain('client_id=');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('OPENID_CLIENT_ID is not set'),
);
});
it('falls back to logout_hint for cookie-sourced long token', async () => {
const longCookieToken = 'a'.repeat(3000);
cookies.parse.mockReturnValue({
refreshToken: 'cookie-rt',
openid_id_token: longCookieToken,
});
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: { destroy: jest.fn() },
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=user%40example.com');
expect(body.redirect).toContain('client_id=my-client-id');
});
it('keeps id_token_hint when projected URL length equals the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length;
const exactToken = 'a'.repeat(tokenLength);
const req = buildReq({
session: {
openidTokens: { refreshToken: 'srt', idToken: exactToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=');
expect(body.redirect).not.toContain('logout_hint=');
});
it('falls back to logout_hint when projected URL is one char over the max', async () => {
const baseUrl = new URL('https://idp.example.com/logout');
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
const baseLength = baseUrl.toString().length;
const tokenLength = 2000 - baseLength - '&id_token_hint='.length + 1;
const overToken = 'a'.repeat(tokenLength);
const req = buildReq({
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
session: {
openidTokens: { refreshToken: 'srt', idToken: overToken },
destroy: jest.fn(),
},
});
const res = buildRes();
await logoutController(req, res);
const body = res.send.mock.calls[0][0];
expect(body.redirect).not.toContain('id_token_hint=');
expect(body.redirect).toContain('logout_hint=');
});
});
describe('invalid OPENID_MAX_LOGOUT_URL_LENGTH values', () => {
it('silently uses default when value is empty', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for partial numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for zero value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '0';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for negative value', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '-1';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
it('warns and uses default for non-numeric string', async () => {
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = 'abc';
const req = buildReq();
const res = buildRes();
await logoutController(req, res);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
);
const body = res.send.mock.calls[0][0];
expect(body.redirect).toContain('id_token_hint=small-id-token');
});
});
});
@@ -0,0 +1,61 @@
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const {
verifyTOTP,
getTOTPSecret,
verifyBackupCode,
} = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { getUserById } = require('~/models');
/**
* Verifies the 2FA code during login using a temporary token.
*/
const verify2FAWithTempToken = async (req, res) => {
try {
const { tempToken, token, backupCode } = req.body;
if (!tempToken) {
return res.status(400).json({ message: 'Missing temporary token' });
}
let payload;
try {
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
} catch (err) {
logger.error('Failed to verify temporary token:', err);
return res.status(401).json({ message: 'Invalid or expired temporary token' });
}
const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
if (!user || !user.twoFactorEnabled) {
return res.status(400).json({ message: '2FA is not enabled for this user' });
}
const secret = await getTOTPSecret(user.totpSecret);
let isVerified = false;
if (token) {
isVerified = await verifyTOTP(secret, token);
} else if (backupCode) {
isVerified = await verifyBackupCode({ user, backupCode });
}
if (!isVerified) {
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
}
const userData = user.toObject ? user.toObject() : { ...user };
delete userData.__v;
delete userData.password;
delete userData.totpSecret;
delete userData.backupCodes;
userData.id = user._id.toString();
const authToken = await setAuthTokens(user._id, res, null, req);
return res.status(200).json({ token: authToken, user: userData });
} catch (err) {
logger.error('[verify2FAWithTempToken]', err);
return res.status(500).json({ message: 'Something went wrong' });
}
};
module.exports = { verify2FAWithTempToken };
+92
View File
@@ -0,0 +1,92 @@
const { CacheKeys } = require('librechat-data-provider');
const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
const {
isEnabled,
getAdminPanelUrl,
isAdminPanelRedirect,
generateAdminExchangeCode,
} = require('@librechat/api');
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
const getLogStores = require('~/cache/getLogStores');
const { checkBan } = require('~/server/middleware');
const { generateToken } = require('~/models');
const domains = {
client: process.env.DOMAIN_CLIENT,
server: process.env.DOMAIN_SERVER,
};
function createOAuthHandler(redirectUri = domains.client) {
/**
* A handler to process OAuth authentication results.
* @type {Function}
* @param {ServerRequest} req - Express request object.
* @param {ServerResponse} res - Express response object.
* @param {NextFunction} next - Express next middleware function.
*/
return async (req, res, next) => {
try {
if (res.headersSent) {
return;
}
await checkBan(req, res);
if (req.banned) {
return;
}
/** Check if this is an admin panel redirect (cross-origin or same-origin subpath) */
if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
/** For admin panel, generate exchange code instead of setting cookies */
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const token = await generateToken(req.user, sessionExpiry);
/** Get refresh token from tokenset for OpenID users */
const refreshToken =
req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true
? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token
: undefined;
const expiresAt = Date.now() + sessionExpiry;
const callbackUrl = new URL(redirectUri);
const exchangeCode = await generateAdminExchangeCode(
cache,
req.user,
token,
refreshToken,
callbackUrl.origin,
req.pkceChallenge,
expiresAt,
);
callbackUrl.searchParams.set('code', exchangeCode);
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
return res.redirect(callbackUrl.toString());
}
/** Standard OAuth flow - set cookies and redirect */
if (
req.user &&
req.user.provider == 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
) {
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
setOpenIDAuthTokens(req.user.tokenset, req, res, {
userId: req.user._id.toString(),
tenantId: req.user.tenantId,
});
} else {
await setAuthTokens(req.user._id, res, null, req);
}
res.redirect(redirectUri);
} catch (err) {
logger.error('Error in setting authentication tokens:', err);
next(err);
}
};
}
module.exports = {
createOAuthHandler,
};
+151
View File
@@ -0,0 +1,151 @@
const mockIsEnabled = jest.fn();
const mockGetAdminPanelUrl = jest.fn();
const mockIsAdminPanelRedirect = jest.fn();
const mockGenerateAdminExchangeCode = jest.fn();
const mockSyncUserEntraGroupMemberships = jest.fn();
const mockSetAuthTokens = jest.fn();
const mockSetOpenIDAuthTokens = jest.fn();
const mockGetLogStores = jest.fn();
const mockCheckBan = jest.fn();
const mockGenerateToken = jest.fn();
const mockLogger = { info: jest.fn(), error: jest.fn() };
jest.mock('librechat-data-provider', () => ({
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: mockLogger,
DEFAULT_SESSION_EXPIRY: 60000,
}));
jest.mock('@librechat/api', () => ({
isEnabled: (...args) => mockIsEnabled(...args),
getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args),
isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args),
generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args),
}));
jest.mock('~/server/services/PermissionService', () => ({
syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args),
}));
jest.mock('~/server/services/AuthService', () => ({
setAuthTokens: (...args) => mockSetAuthTokens(...args),
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
}));
jest.mock(
'~/cache/getLogStores',
() =>
(...args) =>
mockGetLogStores(...args),
);
jest.mock('~/server/middleware', () => ({
checkBan: (...args) => mockCheckBan(...args),
}));
jest.mock('~/models', () => ({
generateToken: (...args) => mockGenerateToken(...args),
}));
const { createOAuthHandler } = require('./oauth');
const ORIGINAL_ENV = process.env;
function buildReq(overrides = {}) {
return {
user: {
_id: 'user-123',
email: 'admin@example.com',
provider: 'openid',
tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' },
federatedTokens: { refresh_token: 'federated-refresh-token' },
},
pkceChallenge: 'pkce-challenge',
banned: false,
...overrides,
};
}
function buildRes() {
return {
headersSent: false,
redirect: jest.fn(),
};
}
describe('createOAuthHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...ORIGINAL_ENV,
DOMAIN_CLIENT: 'http://localhost:3080',
DOMAIN_SERVER: 'http://localhost:3080',
OPENID_REUSE_TOKENS: 'false',
};
mockIsEnabled.mockImplementation((value) => value === 'true' || value === true);
mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com');
mockIsAdminPanelRedirect.mockReturnValue(true);
mockGetLogStores.mockReturnValue({});
mockCheckBan.mockResolvedValue(undefined);
mockGenerateToken.mockResolvedValue('jwt-token');
mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => {
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
{},
req.user,
'jwt-token',
undefined,
'http://admin.example.com',
'pkce-challenge',
expect.any(Number),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://admin.example.com/auth/openid/callback?code=exchange-code',
);
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
const req = buildReq();
const res = buildRes();
const next = jest.fn();
await handler(req, res, next);
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
{},
req.user,
'jwt-token',
'openid-refresh-token',
'http://admin.example.com',
'pkce-challenge',
expect.any(Number),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://admin.example.com/auth/openid/callback?code=exchange-code',
);
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
expect(mockSetAuthTokens).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
});