diff --git a/.env.example b/.env.example index 3f185e0..ca58549 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 92ec55f..df3bf99 100644 --- a/README.md +++ b/README.md @@ -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:` 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:` 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 @@ -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 ``` diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts new file mode 100644 index 0000000..5bb66ca --- /dev/null +++ b/src/server/auth.oauth.test.ts @@ -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(); +const sessionState: { data: Record } = { 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', + ); + }); +}); diff --git a/src/server/auth.ts b/src/server/auth.ts index 9c66005..240d400 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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'; @@ -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, @@ -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 }); @@ -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(); diff --git a/src/server/utils/oauth.test.ts b/src/server/utils/oauth.test.ts new file mode 100644 index 0000000..d355359 --- /dev/null +++ b/src/server/utils/oauth.test.ts @@ -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); + }); +}); diff --git a/src/server/utils/oauth.ts b/src/server/utils/oauth.ts new file mode 100644 index 0000000..8b8142d --- /dev/null +++ b/src/server/utils/oauth.ts @@ -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 }), + }; +}