mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
Five validated findings from the initial bot pass: socialLogin.js: mirror the OpenID migrate-or-reject pattern on the email fallback. When an existing user is found by email and the stored provider id is empty, persist the refreshed sub so the refresh path can later bind to it. When the stored id is present and differs, reject as AUTH_FAILED to prevent identity-swap, matching the existing OpenID behavior in packages/api/src/auth/openid.ts. oauth.js: scope the non-OpenID admin refresh-token forwarding to provider === 'google'. The previous else branch would have forwarded a Discord refresh token (passport-discord supplies one) into the admin exchange payload even though /api/admin/oauth/refresh only accepts openid or google, leaving the admin client with a token it could not refresh. admin/auth.js (refreshGoogleAdminSession): drop id_token from the mandatory-fields check. Google's OAuth refresh response is documented to include id_token only conditionally, so the previous mandatory check broke refresh whenever Google omitted it. Decode id_token when present (fast path); when absent, call Google's userinfo endpoint with the access token to read sub. Wrap tokenResponse.json() in try/catch and return IDP_INCOMPLETE on parse failure instead of a generic 500. Tighten access_token to a typeof string check. admin/auth.js (refreshGoogleAdminSession): reuse serializeUserForExchange for the response user so the Google refresh shape matches /oauth/exchange and the OpenID branch exactly (full _id, id, email, name, username, role, avatar, provider, openidId). The previous Google-specific subset dropped fields the admin client relies on for later provider-specific refreshes and disambiguation. Tests cover each fix: socialLogin's migration and rejection cases, the oauth.js Discord-gating case, the userinfo fallback path on missing id_token, CLAIMS_INCOMPLETE when both id_token and userinfo are absent, IDP_INCOMPLETE on a non-JSON token body, and the full response shape on the happy path.
216 lines
6.5 KiB
JavaScript
216 lines
6.5 KiB
JavaScript
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();
|
|
});
|
|
|
|
it('forwards the refresh token from req.authInfo for non-openid admin providers', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/google/callback');
|
|
const req = buildReq({
|
|
user: { _id: 'user-9', email: 'g@example.com', provider: 'google' },
|
|
authInfo: { refreshToken: 'google-refresh-token' },
|
|
});
|
|
const res = buildRes();
|
|
const next = jest.fn();
|
|
|
|
await handler(req, res, next);
|
|
|
|
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
|
{},
|
|
req.user,
|
|
'jwt-token',
|
|
'google-refresh-token',
|
|
'http://admin.example.com',
|
|
'pkce-challenge',
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('omits the refresh token when a non-openid admin login has no authInfo', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/google/callback');
|
|
const req = buildReq({
|
|
user: { _id: 'user-9', email: 'g@example.com', provider: 'google' },
|
|
});
|
|
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),
|
|
);
|
|
});
|
|
|
|
it('does not forward refresh tokens for admin providers other than google or openid', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/discord/callback');
|
|
const req = buildReq({
|
|
user: { _id: 'user-9', email: 'd@example.com', provider: 'discord' },
|
|
authInfo: { refreshToken: 'discord-refresh-token' },
|
|
});
|
|
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),
|
|
);
|
|
});
|
|
});
|