diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index 3502be8a4c..ede02febb2 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -45,7 +45,9 @@ function createOAuthHandler(redirectUri = domains.client) { /** Get refresh token from tokenset for OpenID users */ const refreshToken = - req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token; + 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); diff --git a/api/server/controllers/auth/oauth.spec.js b/api/server/controllers/auth/oauth.spec.js new file mode 100644 index 0000000000..4a20442d4f --- /dev/null +++ b/api/server/controllers/auth/oauth.spec.js @@ -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(); + }); +});