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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ SESSION_SECRET=
# Useful when the server reaches LibreChat on a different URL than the browser.
# API_SERVER_URL=http://localhost:3080

# Browser-facing URL of the admin panel itself. When set, its origin is sent to
# LibreChat's OAuth exchange instead of deriving it from request headers. Only
# needed when a proxy strips the Origin header and forwards no x-forwarded-proto
# or x-forwarded-host metadata. Mirror LibreChat's ADMIN_PANEL_URL value.
# ADMIN_PANEL_PUBLIC_URL=https://example.com/admin

# Force SSO-only login (hides the email/password form)
# ADMIN_SSO_ONLY=false

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 |
| `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_PANEL_PUBLIC_URL` | No | Derived from request headers | Browser-facing panel URL; set when a proxy strips `Origin` and forwards no proto/host metadata |
| `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 |
Expand Down
257 changes: 257 additions & 0 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,102 @@ describe('verifyAdminTokenFn', () => {
});

describe('oauthExchangeFn', () => {
const originalPublicUrl = process.env.ADMIN_PANEL_PUBLIC_URL;

beforeEach(() => {
fetchMock.mockReset();
updateSession.mockReset();
warnSpy.mockClear();
sessionState.data = {};
requestHeaders.clear();
vi.stubGlobal('fetch', fetchMock);
delete process.env.ADMIN_PANEL_PUBLIC_URL;
});

afterEach(() => {
if (originalPublicUrl === undefined) delete process.env.ADMIN_PANEL_PUBLIC_URL;
else process.env.ADMIN_PANEL_PUBLIC_URL = originalPublicUrl;
});

it('sends the configured ADMIN_PANEL_PUBLIC_URL origin ahead of any header derivation', async () => {
process.env.ADMIN_PANEL_PUBLIC_URL = 'https://public.example.com/admin';
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('origin', 'http://other.test');
requestHeaders.set('host', 'admin-panel:3000');
requestHeaders.set('x-forwarded-host', 'internal-lb.local');
requestHeaders.set('x-forwarded-proto', 'http');
requestHeaders.set('referer', 'https://login.microsoftonline.com/');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '3'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://public.example.com',
},
body: JSON.stringify({ code: '3'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('rejects a non-web ADMIN_PANEL_PUBLIC_URL scheme instead of sending a null origin', async () => {
process.env.ADMIN_PANEL_PUBLIC_URL = 'file:///etc/passwd';
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('origin', 'https://example.com');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '8'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: '8'.repeat(64), code_verifier: 'verifier-123' }),
});
expect(warnSpy).toHaveBeenCalledWith(
'[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:',
'file:///etc/passwd',
);
});

it('falls back to header derivation when ADMIN_PANEL_PUBLIC_URL is malformed', async () => {
process.env.ADMIN_PANEL_PUBLIC_URL = 'not a url';
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('origin', 'https://example.com');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '4'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: '4'.repeat(64), code_verifier: 'verifier-123' }),
});
expect(warnSpy).toHaveBeenCalledWith(
'[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:',
'not a url',
);
});

it('exchanges the callback code with the PKCE verifier stored in the admin session', async () => {
Expand Down Expand Up @@ -243,6 +332,174 @@ describe('oauthExchangeFn', () => {
);
});

it('derives the exchange Origin from the serving host when the callback carries the IdP referer', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('referer', 'https://login.microsoftonline.com/');
requestHeaders.set('host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

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

expect(result).toEqual({
error: false,
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
});
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'c'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('uses the first x-forwarded-proto value when the proxy chain appends multiple', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https, http');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'd'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'd'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('prefers x-forwarded-host over a proxy-rewritten upstream host', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'admin-panel:3000');
requestHeaders.set('x-forwarded-host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'e'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'e'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('uses the first x-forwarded-host value when the proxy chain appends multiple', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'admin-panel:3000');
requestHeaders.set('x-forwarded-host', 'example.com, internal-lb.local');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'f'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'f'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('recovers the https scheme from a same-host referer when x-forwarded-proto is absent', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'example.com');
requestHeaders.set('referer', 'https://example.com/admin/auth/openid/callback?code=abc');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '1'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: '1'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('matches a same-host referer when the serving authority carries an explicit default port', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'example.com:443');
requestHeaders.set('referer', 'https://example.com/admin/auth/openid/callback?code=abc');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '7'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: '7'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('never adopts a foreign referer origin even when x-forwarded-proto is absent', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'example.com');
requestHeaders.set('referer', 'https://login.microsoftonline.com/');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: '2'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'http://example.com',
},
body: JSON.stringify({ code: '2'.repeat(64), code_verifier: 'verifier-123' }),
});
});

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

Expand Down
74 changes: 61 additions & 13 deletions src/server/auth.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,71 @@ function extractCookieValue(response: Response, name: string): string | undefine
return undefined;
}

function getRequestOrigin(): string | undefined {
const origin = getRequestHeader('origin');
if (origin) return origin;
/** Returns the origin of ADMIN_PANEL_PUBLIC_URL when configured, giving deployments behind proxies that strip Origin and forward no proto/host metadata a deterministic override. */
function getConfiguredPublicOrigin(): string | undefined {
const publicUrl = process.env.ADMIN_PANEL_PUBLIC_URL;
if (!publicUrl) return undefined;
try {
const url = new URL(publicUrl);
if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== 'null') {
return url.origin;
}
} catch {
/* fall through to the warning */
}
console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl);
return undefined;
}

const DEFAULT_SCHEME_PORTS: Record<string, string> = { 'https:': ':443', 'http:': ':80' };

/** Returns the referer's origin only when its host matches the serving host, so a foreign referer (e.g. an IdP such as Azure EntraID's login.microsoftonline.com) can never be forwarded as the panel's own origin. `URL` strips the scheme's default port from the referer host, so an explicit default port on the serving authority (`example.com:443`) is stripped before comparing. */
function getSameHostRefererOrigin(host: string): string | undefined {
const referer = getRequestHeader('referer');
if (referer) {
try {
return new URL(referer).origin;
} catch {
return undefined;
}
if (!referer) return undefined;
try {
const url = new URL(referer);
const defaultPort = DEFAULT_SCHEME_PORTS[url.protocol] ?? '';
const servingHost = host.toLowerCase();
const normalizedHost =
defaultPort && servingHost.endsWith(defaultPort)
? servingHost.slice(0, -defaultPort.length)
: servingHost;
return url.host.toLowerCase() === normalizedHost ? url.origin : undefined;
} catch {
return undefined;
}
}

/**
* Resolves the admin panel's browser-visible origin for LibreChat's exchange-code
* origin binding.
*
* Order: the configured `ADMIN_PANEL_PUBLIC_URL` origin, then the `origin` header,
* then the first `x-forwarded-host` value (Host-rewriting proxies replace `host` with
* the internal upstream authority) combined with the first `x-forwarded-proto` value.
* When no forwarded proto is available, a same-host referer recovers the scheme for
* HTTPS deployments whose proxy strips `Origin` without setting `x-forwarded-proto`.
* A foreign referer is never used: it identifies whatever page or IdP initiated the
* request, and forwarding it makes LibreChat reject the exchange code as expired even
* though authentication succeeded.
*/
function getRequestOrigin(): string | undefined {
const configuredOrigin = getConfiguredPublicOrigin();
if (configuredOrigin) return configuredOrigin;

const host = getRequestHeader('host');
const origin = getRequestHeader('origin');
if (origin) return origin;

const forwardedHost = getRequestHeader('x-forwarded-host');
const host = forwardedHost?.split(',')[0]?.trim() || getRequestHeader('host');
if (!host) return undefined;

const proto = getRequestHeader('x-forwarded-proto') ?? 'http';
return `${proto}://${host}`;
const forwardedProto = getRequestHeader('x-forwarded-proto');
const proto = forwardedProto?.split(',')[0]?.trim();
if (proto) return `${proto}://${host}`;

return getSameHostRefererOrigin(host) ?? `http://${host}`;
Comment thread
dustinhealy marked this conversation as resolved.
}

export const adminLoginFn = createServerFn({ method: 'POST' })
Expand Down Expand Up @@ -197,7 +244,8 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn
return { valid: false, error: 'Session expired due to inactivity' };
}

const needsRevalidation = !lastVerified || now - lastVerified > sessionConfig.revalidationInterval;
const needsRevalidation =
!lastVerified || now - lastVerified > sessionConfig.revalidationInterval;

if (needsRevalidation) {
try {
Expand Down