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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ SESSION_SECRET=

# Whether the session cookie should only be sent over HTTPS.
# Defaults to true in production, false otherwise.
# SESSION_COOKIE_SECURE=true
# Set to false only for HTTP-only deployments where browsers drop Secure cookies.
# SESSION_COOKIE_SECURE=false

# ── Cache Control ────────────────────────────────────────────
# These mirror LibreChat's cache env vars. ADMIN_PANEL_* variants
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ docker compose down # stop

#### Environment variables

| Variable | Required | Default | Description |
| ------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `PORT` | No | `3000` | Port the admin panel listens on |
| `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) |
| `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:<port>` in Docker |
| `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only |
| `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms |
| Variable | Required | Default | Description |
| ------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `PORT` | No | `3000` | Port the admin panel listens on |
| `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) |
| `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_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 |

For OpenID SSO, the admin panel stores a short-lived PKCE verifier in the
`admin-session` cookie before redirecting to LibreChat. If the admin panel is
served over plain HTTP while running in production mode, browsers reject a
`Secure` session cookie and the callback cannot complete the PKCE exchange. In
that deployment shape, set `SESSION_COOKIE_SECURE=false` on the admin panel.
Set the same override on LibreChat itself when LibreChat is also reached over
plain HTTP, so its OAuth and auth cookies are not dropped either.

#### Standalone Docker build

Expand All @@ -54,5 +64,6 @@ docker run -p 3000:3000 \
--add-host=host.docker.internal:host-gateway \
-e SESSION_SECRET=your-secret-here-at-least-32-characters \
-e VITE_API_BASE_URL=http://host.docker.internal:3080 \
-e SESSION_COOKIE_SECURE=false \
librechat-admin-panel
```
118 changes: 118 additions & 0 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MISSING_PKCE_VERIFIER_MESSAGE } from './utils/oauth';

const fetchMock = vi.fn();
const updateSession = vi.fn();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const requestHeaders = new Map<string, string>();
const sessionState: { data: Record<string, unknown> } = { data: {} };

vi.mock('@tanstack/react-start', () => ({
createServerFn: () => ({
handler: (fn: (...args: unknown[]) => unknown) => fn,
inputValidator: () => ({
handler: (fn: (...args: unknown[]) => unknown) => fn,
}),
}),
}));

vi.mock('@tanstack/react-start/server', () => ({
getRequestHeader: (name: string) => requestHeaders.get(name.toLowerCase()),
}));

vi.mock('@tanstack/react-query', () => ({
queryOptions: (opts: unknown) => opts,
}));

vi.mock('./session', () => ({
SESSION_CONFIG: {
revalidationInterval: 60_000,
idleTimeout: 30 * 60 * 1000,
},
useAppSession: vi.fn(async () => ({
data: sessionState.data,
update: updateSession,
})),
}));

vi.mock('./utils/url', () => ({
getApiBaseUrl: () => 'http://admin.test',
getServerApiUrl: () => 'http://librechat.test',
}));

vi.mock('./utils/refresh', () => ({
refreshAdminTokenDeduped: vi.fn(),
}));

import { oauthExchangeFn } from './auth';

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

describe('oauthExchangeFn', () => {
beforeEach(() => {
fetchMock.mockReset();
updateSession.mockReset();
warnSpy.mockClear();
sessionState.data = {};
requestHeaders.clear();
vi.stubGlobal('fetch', fetchMock);
});

it('exchanges the callback code with the PKCE verifier stored in the admin session', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('origin', 'http://admin.test');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
refreshToken: 'refresh-token',
expiresAt: 123456,
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

const result = await oauthExchangeFn({ data: { code: 'a'.repeat(64) } });

expect(result).toEqual({
error: false,
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'http://admin.test',
},
body: JSON.stringify({ code: 'a'.repeat(64), code_verifier: 'verifier-123' }),
});
expect(updateSession).toHaveBeenCalledWith(
expect.objectContaining({
token: 'jwt-token',
refreshToken: 'refresh-token',
tokenProvider: 'openid',
codeVerifier: undefined,
}),
);
});

it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => {
sessionState.data = {};

const result = await oauthExchangeFn({ data: { code: 'b'.repeat(64) } });

expect(result).toEqual({
error: true,
message: MISSING_PKCE_VERIFIER_MESSAGE,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(updateSession).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'[oauthExchangeFn] Missing PKCE verifier from admin session; check SESSION_COOKIE_SECURE for HTTP deployments',
);
});
});
19 changes: 16 additions & 3 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createServerFn } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
import type * as t from '@/types';
import { getApiBaseUrl, getServerApiUrl } from './utils/url';
import { buildOAuthExchangePayload } from './utils/oauth';
import { refreshAdminTokenDeduped } from './utils/refresh';
import { useAppSession, SESSION_CONFIG } from './session';

Expand Down Expand Up @@ -210,7 +211,11 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn
}
if (response.status === 401) {
if (refreshToken) {
const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user.id);
const refreshed = await refreshAdminTokenDeduped(
refreshToken,
tokenProvider,
user.id,
);
if (refreshed) {
const refreshedSession = {
token: refreshed.token,
Expand Down Expand Up @@ -347,7 +352,8 @@ export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async ()
const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex');
authUrl.searchParams.set('code_challenge', codeChallenge);
if (requestOrigin) authUrl.searchParams.set('redirect_uri', `${requestOrigin}/auth/openid/callback`);
if (requestOrigin)
authUrl.searchParams.set('redirect_uri', `${requestOrigin}/auth/openid/callback`);

const session = await useAppSession();
await session.update({ codeVerifier });
Expand All @@ -371,11 +377,18 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' })

const session = await useAppSession();
const { codeVerifier } = session.data;
const exchangePayload = buildOAuthExchangePayload(data.code, codeVerifier);
if (!exchangePayload.ok) {
console.warn(
'[oauthExchangeFn] Missing PKCE verifier from admin session; check SESSION_COOKIE_SECURE for HTTP deployments',
);
return { error: true, message: exchangePayload.message };
}

const response = await fetch(`${getServerApiUrl()}/api/admin/oauth/exchange`, {
method: 'POST',
headers,
body: JSON.stringify({ code: data.code, code_verifier: codeVerifier }),
body: exchangePayload.body,
});

const responseData = await response.json();
Expand Down
28 changes: 28 additions & 0 deletions src/server/utils/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { buildOAuthExchangePayload, MISSING_PKCE_VERIFIER_MESSAGE } from './oauth';

describe('buildOAuthExchangePayload', () => {
it('includes the stored PKCE verifier in the exchange body', () => {
const result = buildOAuthExchangePayload('a'.repeat(64), 'verifier-123');

expect(result).toEqual({
ok: true,
body: JSON.stringify({ code: 'a'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('returns a targeted error instead of omitting an undefined verifier', () => {
const result = buildOAuthExchangePayload('a'.repeat(64), undefined);

expect(result).toEqual({
ok: false,
message: MISSING_PKCE_VERIFIER_MESSAGE,
});
});

it('rejects an empty verifier because LibreChat will enforce the stored challenge', () => {
const result = buildOAuthExchangePayload('a'.repeat(64), '');

expect(result.ok).toBe(false);
});
});
29 changes: 29 additions & 0 deletions src/server/utils/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const MISSING_PKCE_VERIFIER_MESSAGE =
'SSO session state was lost before the callback. For HTTP deployments, set SESSION_COOKIE_SECURE=false on the admin panel so the admin-session cookie is accepted.';

type OAuthExchangePayloadResult =
| {
ok: true;
body: string;
}
| {
ok: false;
message: string;
};

export function buildOAuthExchangePayload(
code: string,
codeVerifier: unknown,
): OAuthExchangePayloadResult {
if (typeof codeVerifier !== 'string' || codeVerifier.length === 0) {
return {
ok: false,
message: MISSING_PKCE_VERIFIER_MESSAGE,
};
}

return {
ok: true,
body: JSON.stringify({ code, code_verifier: codeVerifier }),
};
}