Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ SESSION_SECRET=
# The endpoint returns 401 if this is unset or the token does not match.
# ADMIN_PANEL_METRICS_SECRET=

# URL base path for serving the admin panel under a subpath (e.g., /adminpanel).
# Must match at build time and runtime. Leave unset for root (/).
# VITE_BASE_PATH=/adminpanel

# Browser-facing URL of the LibreChat API server (used for OAuth redirects).
# Defaults to http://localhost:3080
# VITE_API_BASE_URL=http://localhost:3080
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ RUN bun install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules node_modules
COPY . .
ARG VITE_BASE_PATH=/
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
ENV NODE_ENV=production
RUN bun run build

Expand All @@ -39,7 +41,7 @@ USER bun
ENV PORT=3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"
CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}/health\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"

EXPOSE 3000
CMD ["bun", "run", "start"]
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ docker compose down # stop
| `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 |
| `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime |
| `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 |
Expand All @@ -66,4 +67,13 @@ docker run -p 3000:3000 \
-e VITE_API_BASE_URL=http://host.docker.internal:3080 \
-e SESSION_COOKIE_SECURE=false \
librechat-admin-panel

# To serve under a subpath (e.g., /adminpanel):
docker build -t librechat-admin-panel --build-arg VITE_BASE_PATH=/adminpanel .
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 VITE_BASE_PATH=/adminpanel \
librechat-admin-panel
```
12 changes: 9 additions & 3 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const CLIENT_DIR = join(import.meta.dir, 'dist', 'client');
const SERVER_ENTRY = new URL('./dist/server/server.js', import.meta.url);

const env = process.env;
const BASE_PATH = (env.VITE_BASE_PATH || '').replace(/\/$/, '');

const ONE_DAY = 86400;
const rawMaxAge = Number(env.ADMIN_PANEL_STATIC_CACHE_MAX_AGE ?? env.STATIC_CACHE_MAX_AGE);
Expand Down Expand Up @@ -63,7 +64,7 @@ async function buildStaticRoutes(): Promise<Record<string, (req: Request) => Pro
for await (const path of new Glob('**/*').scan(CLIENT_DIR)) {
const file = Bun.file(`${CLIENT_DIR}/${path}`);
const cache = getCacheHeaders(path);
const routePath = `/${path}`;
const routePath = `${BASE_PATH}/${path}`;
routes[routePath] = (req) =>
withHttpMetrics(
req,
Expand All @@ -79,9 +80,14 @@ const server = Bun.serve({
routes: {
...(await buildStaticRoutes()),
'/metrics': (req) => metricsResponse(req),
'/health': () => new Response('ok'),
...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}),
'/*': async (req) => {
const url = new URL(req.url);
const res = await withHttpMetrics(req, url.pathname, () => handler.fetch(req));
const metricsPath = BASE_PATH && url.pathname.startsWith(BASE_PATH)
? url.pathname.slice(BASE_PATH.length) || '/'
: url.pathname;
const res = await withHttpMetrics(req, metricsPath, () => handler.fetch(req));
const patched = new Response(res.body, res);
for (const [k, v] of Object.entries(NO_CACHE)) {
patched.headers.set(k, v);
Expand All @@ -91,7 +97,7 @@ const server = Bun.serve({
},
});

console.log(`Admin panel listening on http://localhost:${server.port}`);
console.log(`Admin panel listening on http://localhost:${server.port}${BASE_PATH}/`);

if (!process.env.ADMIN_PANEL_METRICS_SECRET) {
console.warn(
Expand Down
1 change: 1 addition & 0 deletions src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function getRouter() {
const queryClient = new QueryClient();
const router = createTanStackRouter({
routeTree,
basepath: import.meta.env.VITE_BASE_PATH || '/',
context: { queryClient },
scrollRestoration: true,
defaultPreload: 'intent',
Expand Down
2 changes: 1 addition & 1 deletion src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const Route = createRootRoute({
},
{
rel: 'icon',
href: '/favicon.ico',
href: `${(import.meta.env.VITE_BASE_PATH || '').replace(/\/$/, '')}/favicon.ico`,
},
],
}),
Expand Down
9 changes: 5 additions & 4 deletions src/routes/auth/openid/callback.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { createFileRoute, redirect, Link } from '@tanstack/react-router';
import { oauthExchangeFn } from '@/server';
import { useLocalize } from '@/hooks';

Expand Down Expand Up @@ -62,12 +62,13 @@ function OpenIdCallback() {
{localize('com_auth_sso_error_title')}
</h1>
<p className="text-sm text-(--cui-color-text-muted)">{errorMessage}</p>
<a
href="/login"
<Link
to="/login"
search={{ redirect: '/' }}
className="mt-2 rounded-lg border border-(--cui-color-stroke-default) bg-transparent px-4 py-2 text-sm font-medium text-(--cui-color-text-default) no-underline transition-colors hover:bg-(--cui-color-background-hover)"
>
{localize('com_auth_sso_back_to_login')}
</a>
</Link>
</div>
</div>
);
Expand Down
3 changes: 0 additions & 3 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,13 +347,10 @@ export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async ()
try {
const baseUrl = getApiBaseUrl();
const authUrl = new URL(`${baseUrl}/api/admin/oauth/openid`);
const requestOrigin = getRequestOrigin();

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`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSO redirect_uri removed instead of made subpath-aware

Medium Severity

The openidLoginFn previously sent a redirect_uri parameter to LibreChat's OAuth endpoint so it knew where to redirect after authentication. This was removed entirely instead of being updated to include the base path. Without redirect_uri, LibreChat must determine the callback URL from its own config — which won't include the subpath (e.g., /adminpanel/auth/openid/callback). This means SSO is likely broken for subpath deployments, which is the core purpose of this PR. Even for non-subpath users, this is a behavioral regression since the callback URL is no longer explicitly communicated to the OAuth flow.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 143d976. Configure here.


const session = await useAppSession();
await session.update({ codeVerifier });
Expand Down
3 changes: 3 additions & 0 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'development') {
);
}

const sessionCookiePath = process.env.VITE_BASE_PATH || '/';

export function useAppSession(): ReturnType<typeof useSession<t.SessionData>> {
return useSession<t.SessionData>({
name: 'admin-session',
password: sessionSecret || '',
cookie: {
path: sessionCookiePath,
secure: sessionCookieSecure,
sameSite: 'lax',
httpOnly: true,
Expand Down
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

const config = defineConfig({
base: process.env.VITE_BASE_PATH || '/',
plugins: [
devtools(),
tailwindcss(),
Expand Down