Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` 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 |

Expand Down
69 changes: 67 additions & 2 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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), {
Expand Down Expand Up @@ -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 });
});
});
3 changes: 3 additions & 0 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down