Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
207 changes: 207 additions & 0 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,75 @@ 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('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 +305,151 @@ 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('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
59 changes: 47 additions & 12 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,59 @@ 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 {
return new URL(publicUrl).origin;
Comment thread
dustinhealy marked this conversation as resolved.
Outdated
} catch {
console.warn('[getRequestOrigin] Ignoring malformed ADMIN_PANEL_PUBLIC_URL:', publicUrl);
return undefined;
}
}

/** 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. */
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);
return url.host.toLowerCase() === host.toLowerCase() ? url.origin : undefined;
Comment thread
dustinhealy marked this conversation as resolved.
Outdated
} 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