mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🚪 fix: Support Admin Redirect Detection for Same-Origin Subpaths (#14040)
This commit is contained in:
parent
e6f5b6e70a
commit
89931baf22
4 changed files with 126 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue