🚪 fix: Support Admin Redirect Detection for Same-Origin Subpaths (#14040)

This commit is contained in:
Arjun Vijay 2026-07-01 11:40:02 -04:00 committed by GitHub
parent e6f5b6e70a
commit 89931baf22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 13 deletions

View file

@ -36,7 +36,7 @@ function createOAuthHandler(redirectUri = domains.client) {
return;
}
/** Check if this is an admin panel redirect (cross-origin) */
/** Check if this is an admin panel redirect (cross-origin or same-origin subpath) */
if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
/** For admin panel, generate exchange code instead of setting cookies */
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);

View file

@ -38,8 +38,11 @@ kind: Secret
## Admin Panel SSO
When deploying the admin panel at a separate URL, set `librechat.adminPanelUrl`
to the external admin panel base URL. It may include a path, but it should not
Set `librechat.adminPanelUrl` to the admin panel base URL used for OAuth/SSO
redirect, whether the admin panel is deployed on a separate origin
or on the same origin under an admin subpath.
It may include a path, but it should not
end with a trailing `/` because LibreChat appends `/auth/...` callback paths.
```yaml

View file

@ -13,7 +13,12 @@ jest.mock(
{ virtual: true },
);
import { exchangeAdminCode, generateAdminExchangeCode, verifyCodeChallenge } from './exchange';
import {
exchangeAdminCode,
generateAdminExchangeCode,
isAdminPanelRedirect,
verifyCodeChallenge,
} from './exchange';
describe('admin OAuth code exchange', () => {
const user = {
@ -214,4 +219,76 @@ describe('admin OAuth code exchange', () => {
expect(result!.token).toBe('jwt-token');
});
});
describe('isAdminPanelRedirect', () => {
it('returns true for cross-origin admin callback redirects', () => {
expect(
isAdminPanelRedirect(
'https://admin.example.com/auth/openid/callback',
'https://admin.example.com',
'https://chat.example.com',
),
).toBe(true);
});
it('returns true for same-origin callbacks under the admin subpath', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/admin/auth/openid/callback',
'https://chat.example.com/admin',
'https://chat.example.com',
),
).toBe(true);
});
it('returns false for same-origin callbacks outside the admin subpath', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/oauth/openid/callback',
'https://chat.example.com/admin',
'https://chat.example.com',
),
).toBe(false);
});
it('does not treat similarly prefixed paths as admin subpaths', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/administrator/auth/openid/callback',
'https://chat.example.com/admin',
'https://chat.example.com',
),
).toBe(false);
});
it('treats trailing slash variants of admin subpath as equivalent', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/admin/auth/openid/callback',
'https://chat.example.com/admin/',
'https://chat.example.com',
),
).toBe(true);
});
it('returns true when redirect path exactly matches admin subpath', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/admin',
'https://chat.example.com/admin',
'https://chat.example.com',
),
).toBe(true);
});
it('returns false for same-origin root admin URL', () => {
expect(
isAdminPanelRedirect(
'https://chat.example.com/auth/openid/callback',
'https://chat.example.com/',
'https://chat.example.com',
),
).toBe(false);
});
});
});

View file

@ -274,14 +274,29 @@ export async function storeAndStripChallenge(
}
/**
* Checks if the redirect URI is for the admin panel (cross-origin).
* Uses proper URL parsing to compare origins, handling edge cases where
* both URLs might share the same prefix (e.g., localhost:3000 vs localhost:3001).
* Normalizes a URL path by removing any trailing slash, except for the root path.
* @returns The normalized path.
*/
const normalizePath = (path: string): string => {
if (!path || path === '/') {
return '/';
}
return path.endsWith('/') ? path.slice(0, -1) : path;
};
/**
* Checks if the redirect URI targets the admin panel.
*
* Supported cases:
* - Cross-origin admin panel: redirect origin must match admin origin.
* - Same-origin admin panel under a subpath: redirect path must be within
* the configured admin subpath.
*
* @param redirectUri - The redirect URI to check.
* @param adminPanelUrl - The admin panel URL (defaults to ADMIN_PANEL_URL env var)
* @param domainClient - The main client domain
* @returns True if redirecting to admin panel (different origin from main client).
* @returns True if redirecting to admin panel.
*/
export function isAdminPanelRedirect(
redirectUri: string,
@ -289,12 +304,30 @@ export function isAdminPanelRedirect(
domainClient: string,
): boolean {
try {
const redirectOrigin = new URL(redirectUri).origin;
const adminOrigin = new URL(adminPanelUrl).origin;
const clientOrigin = new URL(domainClient).origin;
const redirectURL = new URL(redirectUri);
const adminURL = new URL(adminPanelUrl);
const clientURL = new URL(domainClient);
/** Redirect is for admin panel if it matches admin origin but not main client origin */
return redirectOrigin === adminOrigin && redirectOrigin !== clientOrigin;
const redirectOrigin = redirectURL.origin;
const adminOrigin = adminURL.origin;
const clientOrigin = clientURL.origin;
if (redirectOrigin !== adminOrigin) {
return false;
}
if (adminOrigin !== clientOrigin) {
return true;
}
const adminPath = normalizePath(adminURL.pathname);
const redirectPath = normalizePath(redirectURL.pathname);
if (adminPath === '/') {
return false;
}
return redirectPath === adminPath || redirectPath.startsWith(`${adminPath}/`);
} catch {
/** If URL parsing fails, fall back to simple string comparison */
return redirectUri.startsWith(adminPanelUrl) && !redirectUri.startsWith(domainClient);