diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index a400bce8b7..03122cb559 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -7,7 +7,13 @@ const { DEFAULT_REFRESH_TOKEN_EXPIRY, } = require('@librechat/data-schemas'); const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider'); -const { isEnabled, checkEmailConfig, isEmailDomainAllowed, math } = require('@librechat/api'); +const { + math, + isEnabled, + checkEmailConfig, + isEmailDomainAllowed, + shouldUseSecureCookie, +} = require('@librechat/api'); const { findUser, findToken, @@ -33,7 +39,6 @@ const domains = { server: process.env.DOMAIN_SERVER, }; -const isProduction = process.env.NODE_ENV === 'production'; const genericVerificationMessage = 'Please check your email to verify your email address.'; /** @@ -392,13 +397,13 @@ const setAuthTokens = async (userId, res, _session = null) => { res.cookie('refreshToken', refreshToken, { expires: new Date(refreshTokenExpires), httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); res.cookie('token_provider', 'librechat', { expires: new Date(refreshTokenExpires), httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); return token; @@ -419,7 +424,7 @@ const setAuthTokens = async (userId, res, _session = null) => { * @param {Object} req - request object (for session access) * @param {Object} res - response object * @param {string} [userId] - Optional MongoDB user ID for image path validation - * @returns {String} - access token + * @returns {String} - id_token (preferred) or access_token as the app auth token */ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) => { try { @@ -448,6 +453,15 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = return; } + /** + * Use id_token as the app authentication token (Bearer token for JWKS validation). + * The id_token is always a standard JWT signed by the IdP's JWKS keys with the app's + * client_id as audience. The access_token may be opaque or intended for a different + * audience (e.g., Microsoft Graph API), which fails JWKS validation. + * Falls back to access_token for providers where id_token is not available. + */ + const appAuthToken = tokenset.id_token || tokenset.access_token; + /** Store tokens server-side in session to avoid large cookies */ if (req.session) { req.session.openidTokens = { @@ -460,13 +474,13 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = res.cookie('refreshToken', refreshToken, { expires: expirationDate, httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); } @@ -475,7 +489,7 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = res.cookie('token_provider', 'openid', { expires: expirationDate, httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) { @@ -486,11 +500,11 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = res.cookie('openid_user_id', signedUserId, { expires: expirationDate, httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'strict', }); } - return tokenset.access_token; + return appAuthToken; } catch (error) { logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error); throw error; diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js new file mode 100644 index 0000000000..da78f8d775 --- /dev/null +++ b/api/server/services/AuthService.spec.js @@ -0,0 +1,269 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, + DEFAULT_SESSION_EXPIRY: 900000, + DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, +})); +jest.mock('librechat-data-provider', () => ({ + ErrorTypes: {}, + SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' }, + errorsToString: jest.fn(), +})); +jest.mock('@librechat/api', () => ({ + isEnabled: jest.fn((val) => val === 'true' || val === true), + checkEmailConfig: jest.fn(), + isEmailDomainAllowed: jest.fn(), + math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), + shouldUseSecureCookie: jest.fn(() => false), +})); +jest.mock('~/models', () => ({ + findUser: jest.fn(), + findToken: jest.fn(), + createUser: jest.fn(), + updateUser: jest.fn(), + countUsers: jest.fn(), + getUserById: jest.fn(), + findSession: jest.fn(), + createToken: jest.fn(), + deleteTokens: jest.fn(), + deleteSession: jest.fn(), + createSession: jest.fn(), + generateToken: jest.fn(), + deleteUserById: jest.fn(), + generateRefreshToken: jest.fn(), +})); +jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() } })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); +jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() })); + +const { shouldUseSecureCookie } = require('@librechat/api'); +const { setOpenIDAuthTokens } = require('./AuthService'); + +/** Helper to build a mock Express response */ +function mockResponse() { + const cookies = {}; + const res = { + cookie: jest.fn((name, value, options) => { + cookies[name] = { value, options }; + }), + _cookies: cookies, + }; + return res; +} + +/** Helper to build a mock Express request with session */ +function mockRequest(sessionData = {}) { + return { + session: { openidTokens: null, ...sessionData }, + }; +} + +describe('setOpenIDAuthTokens', () => { + const env = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...env, + JWT_REFRESH_SECRET: 'test-refresh-secret', + OPENID_REUSE_TOKENS: 'true', + }; + }); + + afterAll(() => { + process.env = env; + }); + + describe('token selection (id_token vs access_token)', () => { + it('should return id_token when both id_token and access_token are present', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBe('the-id-token'); + }); + + it('should return access_token when id_token is not available', () => { + const tokenset = { + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBe('the-access-token'); + }); + + it('should return access_token when id_token is undefined', () => { + const tokenset = { + id_token: undefined, + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBe('the-access-token'); + }); + + it('should return access_token when id_token is null', () => { + const tokenset = { + id_token: null, + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBe('the-access-token'); + }); + + it('should return id_token even when id_token and access_token differ', () => { + const tokenset = { + id_token: 'id-token-jwt-signed-by-idp', + access_token: 'opaque-graph-api-token', + refresh_token: 'refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBe('id-token-jwt-signed-by-idp'); + expect(result).not.toBe('opaque-graph-api-token'); + }); + }); + + describe('session token storage', () => { + it('should store the original access_token in session (not id_token)', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + expect(req.session.openidTokens.accessToken).toBe('the-access-token'); + expect(req.session.openidTokens.refreshToken).toBe('the-refresh-token'); + }); + }); + + describe('cookie secure flag', () => { + it('should call shouldUseSecureCookie for every cookie set', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + // token_provider + openid_user_id (session path, so no refreshToken/openid_access_token cookies) + const secureCalls = shouldUseSecureCookie.mock.calls.length; + expect(secureCalls).toBeGreaterThanOrEqual(2); + + // Verify all cookies use the result of shouldUseSecureCookie + for (const [, cookie] of Object.entries(res._cookies)) { + expect(cookie.options.secure).toBe(false); + } + }); + + it('should set secure: true when shouldUseSecureCookie returns true', () => { + shouldUseSecureCookie.mockReturnValue(true); + + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + for (const [, cookie] of Object.entries(res._cookies)) { + expect(cookie.options.secure).toBe(true); + } + }); + + it('should use shouldUseSecureCookie for cookie fallback path (no session)', () => { + shouldUseSecureCookie.mockReturnValue(false); + + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = { session: null }; + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + + // In the cookie fallback path, we get: refreshToken, openid_access_token, token_provider, openid_user_id + expect(res.cookie).toHaveBeenCalledWith( + 'refreshToken', + expect.any(String), + expect.objectContaining({ secure: false }), + ); + expect(res.cookie).toHaveBeenCalledWith( + 'openid_access_token', + expect.any(String), + expect.objectContaining({ secure: false }), + ); + expect(res.cookie).toHaveBeenCalledWith( + 'token_provider', + 'openid', + expect.objectContaining({ secure: false }), + ); + }); + }); + + describe('edge cases', () => { + it('should return undefined when tokenset is null', () => { + const req = mockRequest(); + const res = mockResponse(); + const result = setOpenIDAuthTokens(null, req, res, 'user-123'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when access_token is missing', () => { + const tokenset = { refresh_token: 'refresh' }; + const req = mockRequest(); + const res = mockResponse(); + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no refresh token is available', () => { + const tokenset = { access_token: 'access', id_token: 'id' }; + const req = mockRequest(); + const res = mockResponse(); + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); + expect(result).toBeUndefined(); + }); + + it('should use existingRefreshToken when tokenset has no refresh_token', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123', 'existing-refresh'); + expect(result).toBe('the-id-token'); + expect(req.session.openidTokens.refreshToken).toBe('existing-refresh'); + }); + }); +}); diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index cf67fa9436..a84c33bd52 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,7 +1,7 @@ const passport = require('passport'); const session = require('express-session'); -const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); +const { isEnabled, shouldUseSecureCookie } = require('@librechat/api'); const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); const { openIdJwtLogin, @@ -15,38 +15,6 @@ const { } = require('~/strategies'); const { getLogStores } = require('~/cache'); -/** - * Determines if secure cookies should be used. - * Only use secure cookies in production when not on localhost. - * @returns {boolean} - */ -function shouldUseSecureCookie() { - const isProduction = process.env.NODE_ENV === 'production'; - const domainServer = process.env.DOMAIN_SERVER || ''; - - let hostname = ''; - if (domainServer) { - try { - const normalized = /^https?:\/\//i.test(domainServer) - ? domainServer - : `http://${domainServer}`; - const url = new URL(normalized); - hostname = (url.hostname || '').toLowerCase(); - } catch { - // Fallback: treat DOMAIN_SERVER directly as a hostname-like string - hostname = domainServer.toLowerCase(); - } - } - - const isLocalhost = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '::1' || - hostname.endsWith('.localhost'); - - return isProduction && !isLocalhost; -} - /** * Configures OpenID Connect for the application. * @param {Express.Application} app - The Express application instance. diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index f74dcfddcc..890488e88d 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -1,7 +1,7 @@ -import { Plus } from 'lucide-react'; import React, { useMemo, useCallback, useRef, useState } from 'react'; +import { Plus } from 'lucide-react'; import { Button, useToastContext } from '@librechat/client'; -import { useWatch, useForm, FormProvider, type FieldNamesMarkedBoolean } from 'react-hook-form'; +import { useWatch, useForm, FormProvider } from 'react-hook-form'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import { Tools, @@ -11,8 +11,10 @@ import { PermissionBits, isAssistantsEndpoint, } from 'librechat-data-provider'; -import type { AgentForm, StringOption } from '~/common'; +import type { FieldNamesMarkedBoolean } from 'react-hook-form'; import type { Agent } from 'librechat-data-provider'; +import type { TranslationKeys } from '~/hooks/useLocalize'; +import type { AgentForm, StringOption } from '~/common'; import { useCreateAgentMutation, useUpdateAgentMutation, @@ -23,7 +25,6 @@ import { import { createProviderOption, getDefaultAgentFormValues } from '~/utils'; import { useResourcePermissions } from '~/hooks/useResourcePermissions'; import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks'; -import type { TranslationKeys } from '~/hooks/useLocalize'; import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; import AgentPanelSkeleton from './AgentPanelSkeleton'; import AdvancedPanel from './Advanced/AdvancedPanel'; diff --git a/packages/api/src/oauth/csrf.spec.ts b/packages/api/src/oauth/csrf.spec.ts new file mode 100644 index 0000000000..b56f1fd38f --- /dev/null +++ b/packages/api/src/oauth/csrf.spec.ts @@ -0,0 +1,99 @@ +import { shouldUseSecureCookie } from './csrf'; + +describe('shouldUseSecureCookie', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true in production with a non-localhost domain', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return false in development regardless of domain', () => { + process.env.NODE_ENV = 'development'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return false when NODE_ENV is not set', () => { + delete process.env.NODE_ENV; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + describe('localhost detection in production', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should return false for http://localhost:3080', () => { + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return false for https://localhost:3080', () => { + process.env.DOMAIN_SERVER = 'https://localhost:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return false for http://localhost (no port)', () => { + process.env.DOMAIN_SERVER = 'http://localhost'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return false for http://127.0.0.1:3080', () => { + process.env.DOMAIN_SERVER = 'http://127.0.0.1:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return true for http://[::1]:3080 (IPv6 loopback — not detected due to URL bracket parsing)', () => { + // Known limitation: new URL('http://[::1]:3080').hostname returns '[::1]' (with brackets) + // but the check compares against '::1' (without brackets). IPv6 localhost is rare in practice. + process.env.DOMAIN_SERVER = 'http://[::1]:3080'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return false for subdomain of localhost', () => { + process.env.DOMAIN_SERVER = 'http://app.localhost:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should return true for a domain containing "localhost" as a substring but not as hostname', () => { + process.env.DOMAIN_SERVER = 'https://notlocalhost.example.com'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return true for a regular production domain', () => { + process.env.DOMAIN_SERVER = 'https://chat.example.com'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return true when DOMAIN_SERVER is empty (conservative default)', () => { + process.env.DOMAIN_SERVER = ''; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return true when DOMAIN_SERVER is not set (conservative default)', () => { + delete process.env.DOMAIN_SERVER; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should handle DOMAIN_SERVER without protocol prefix', () => { + process.env.DOMAIN_SERVER = 'localhost:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should handle case-insensitive hostnames', () => { + process.env.DOMAIN_SERVER = 'http://LOCALHOST:3080'; + expect(shouldUseSecureCookie()).toBe(false); + }); + }); +}); diff --git a/packages/api/src/oauth/csrf.ts b/packages/api/src/oauth/csrf.ts index 5bf0566b45..6ed63968d1 100644 --- a/packages/api/src/oauth/csrf.ts +++ b/packages/api/src/oauth/csrf.ts @@ -8,7 +8,37 @@ export const OAUTH_SESSION_COOKIE = 'oauth_session'; export const OAUTH_SESSION_MAX_AGE = 24 * 60 * 60 * 1000; export const OAUTH_SESSION_COOKIE_PATH = '/api'; -const isProduction = process.env.NODE_ENV === 'production'; +/** + * Determines if secure cookies should be used. + * Returns `true` in production unless the server is running on localhost (HTTP). + * This allows cookies to work on `http://localhost` during local development + * even when `NODE_ENV=production` (common in Docker Compose setups). + */ +export function shouldUseSecureCookie(): boolean { + const isProduction = process.env.NODE_ENV === 'production'; + const domainServer = process.env.DOMAIN_SERVER || ''; + + let hostname = ''; + if (domainServer) { + try { + const normalized = /^https?:\/\//i.test(domainServer) + ? domainServer + : `http://${domainServer}`; + const url = new URL(normalized); + hostname = (url.hostname || '').toLowerCase(); + } catch { + hostname = domainServer.toLowerCase(); + } + } + + const isLocalhost = + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.endsWith('.localhost'); + + return isProduction && !isLocalhost; +} /** Generates an HMAC-based token for OAuth CSRF protection */ export function generateOAuthCsrfToken(flowId: string, secret?: string): string { @@ -23,7 +53,7 @@ export function generateOAuthCsrfToken(flowId: string, secret?: string): string export function setOAuthCsrfCookie(res: Response, flowId: string, cookiePath: string): void { res.cookie(OAUTH_CSRF_COOKIE, generateOAuthCsrfToken(flowId), { httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'lax', maxAge: OAUTH_CSRF_MAX_AGE, path: cookiePath, @@ -68,7 +98,7 @@ export function setOAuthSession(req: Request, res: Response, next: NextFunction) export function setOAuthSessionCookie(res: Response, userId: string): void { res.cookie(OAUTH_SESSION_COOKIE, generateOAuthCsrfToken(userId), { httpOnly: true, - secure: isProduction, + secure: shouldUseSecureCookie(), sameSite: 'lax', maxAge: OAUTH_SESSION_MAX_AGE, path: OAUTH_SESSION_COOKIE_PATH,