diff --git a/.env.example b/.env.example index ca58549..f0c25a1 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ SESSION_SECRET= # Force SSO-only login (hides the email/password form) # ADMIN_SSO_ONLY=false +# Hide the SSO login button while keeping email/password login enabled. +# Set to false to suppress the admin-panel SSO button (and SSO auto-redirect) +# even when LibreChat has OpenID configured. Takes precedence over ADMIN_SSO_ONLY. +# Defaults to enabled. +# ADMIN_SSO_ENABLED=true + # Session idle timeout in milliseconds (default: 30 minutes) # ADMIN_SESSION_IDLE_TIMEOUT_MS=1800000 diff --git a/README.md b/README.md index df3bf99..bb55191 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ docker compose down # stop | `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:` in Docker | | `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser | | `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only | +| `ADMIN_SSO_ENABLED` | No | `true` | Set `false` to hide the SSO button (and auto-redirect) while keeping email/password login | | `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms | | `SESSION_COOKIE_SECURE` | No | `true` in production, `false` otherwise | Set `false` only for plain-HTTP deployments so the browser keeps the admin session cookie | diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index 5bb66ca..21b9db2 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MISSING_PKCE_VERIFIER_MESSAGE } from './utils/oauth'; const fetchMock = vi.fn(); @@ -44,7 +44,7 @@ vi.mock('./utils/refresh', () => ({ refreshAdminTokenDeduped: vi.fn(), })); -import { oauthExchangeFn } from './auth'; +import { checkOpenIdFn, oauthExchangeFn } from './auth'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -116,3 +116,68 @@ describe('oauthExchangeFn', () => { ); }); }); + +describe('checkOpenIdFn', () => { + const originalSsoEnabled = process.env.ADMIN_SSO_ENABLED; + const originalSsoOnly = process.env.ADMIN_SSO_ONLY; + + beforeEach(() => { + fetchMock.mockReset(); + warnSpy.mockClear(); + vi.stubGlobal('fetch', fetchMock); + delete process.env.ADMIN_SSO_ENABLED; + delete process.env.ADMIN_SSO_ONLY; + }); + + afterEach(() => { + if (originalSsoEnabled === undefined) delete process.env.ADMIN_SSO_ENABLED; + else process.env.ADMIN_SSO_ENABLED = originalSsoEnabled; + if (originalSsoOnly === undefined) delete process.env.ADMIN_SSO_ONLY; + else process.env.ADMIN_SSO_ONLY = originalSsoOnly; + }); + + it('reports SSO available with auto-redirect off by default', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})); + + const result = await checkOpenIdFn(); + + expect(result).toEqual({ available: true, ssoOnly: false }); + expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/openid/check'); + }); + + it('marks the session SSO-only when ADMIN_SSO_ONLY=true', async () => { + process.env.ADMIN_SSO_ONLY = 'true'; + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})); + + const result = await checkOpenIdFn(); + + expect(result).toEqual({ available: true, ssoOnly: true }); + }); + + it('hides the SSO button without calling the backend when ADMIN_SSO_ENABLED=false', async () => { + process.env.ADMIN_SSO_ENABLED = 'false'; + + const result = await checkOpenIdFn(); + + expect(result).toEqual({ available: false, ssoOnly: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('lets ADMIN_SSO_ENABLED=false take precedence over ADMIN_SSO_ONLY=true', async () => { + process.env.ADMIN_SSO_ENABLED = 'false'; + process.env.ADMIN_SSO_ONLY = 'true'; + + const result = await checkOpenIdFn(); + + expect(result).toEqual({ available: false, ssoOnly: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reports SSO unavailable when the backend check fails', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(503, {})); + + const result = await checkOpenIdFn(); + + expect(result).toEqual({ available: false, ssoOnly: false }); + }); +}); diff --git a/src/server/auth.ts b/src/server/auth.ts index 240d400..b0079d4 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -328,6 +328,9 @@ export const openIdCheckOptions = queryOptions({ }); export const checkOpenIdFn = createServerFn({ method: 'GET' }).handler(async () => { + if (process.env.ADMIN_SSO_ENABLED === 'false') { + return { available: false, ssoOnly: false }; + } const checkUrl = `${getServerApiUrl()}/api/admin/oauth/openid/check`; try { const response = await fetch(checkUrl);